Compare commits

..
148 Commits
Author SHA1 Message Date
David Kaya 6068ee42e1 chore: bump to version 0.0.25 2026-04-13 21:08:30 +02:00
David KayaandCopilot 41d19e007d docs: update ARCHITECTURE.md for streaming ownership and provider adapter layer
- Document StreamingTranscriptBuffer as the single owner of text assembly
- Note provider adapter layer (IProviderEventAdapter.TryAdapt) for event
  normalization and how to add new providers
- Add capability metadata to protocol boundary description
- Update assistant-intent events as provider-neutral (not Copilot-specific)
- Add provider event normalization to sidecar runtime boundary

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 16:24:07 +02:00
David KayaandCopilot cc626a3152 test: add streaming regression tests for turn lifecycle and transitions
Add 5 regression tests covering:
- Thinking-to-response transition (reclassify → new message, no flash)
- Concurrent multi-message streaming independence
- Run-updated replacement semantics (no duplicates)
- Full turn lifecycle (intent → deltas → complete → idle clears intent)
- Message-complete content override of streamed content

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 16:21:44 +02:00
David KayaandCopilot 8d08a1df1a refactor: wire normalized intent into activity UI
- ChatPane header now prefers session.currentIntent over the activity-
  derived label from summarizeSessionActivity for the busy status display
- TurnActivityPanel receives currentIntent via props and prefers it over
  extractLatestIntent (report_intent tool-call scanning) when the panel
  is active; falls back to tool-call scan for completed/inactive runs
- This replaces the report_intent-as-status inference pattern with the
  normalized assistant-intent event data from the backend

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 16:20:04 +02:00
David KayaandCopilot 08886f9078 refactor: consolidate streaming text ownership and forward intent events
- Main process now always emits resolved content in message-delta events,
  making it the single authoritative text assembly point
- Renderer no longer imports or runs mergeStreamingText; uses main-process
  resolved content directly with simple append as defensive fallback
- Forward assistant-intent events through the session event pipeline; track
  currentIntent on SessionRecord, cleared on turn completion/cancellation
- Forward reasoning-delta events through the session event pipeline so
  renderers can consume incremental reasoning text
- Add SessionEventKind entries: assistant-intent, reasoning-delta
- Add SessionEventRecord fields: intent, reasoningId, reasoningDelta
- Remove dead applyTurnDelta, applyMessageReclassified, applyAgentActivity
  methods from AryxAppService (superseded by SessionTurnExecutor)
- Remove stale mergeStreamingText and appendRunActivityEvent imports from
  AryxAppService
- Add 5 regression tests: resolved content path, simple append fallback,
  intent set/clear lifecycle, duplicate suppression, intent preservation
  during status transitions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 16:12:41 +02:00
David KayaandCopilot 1fdb1c27d5 refactor: finalize backend streaming ownership
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 15:47:39 +02:00
David KayaandCopilot 4e34d2abfc refactor: unify tool call stream tracking
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 15:33:27 +02:00
David KayaandCopilot 7b9c4d140c refactor: enrich provider turn stream model
Add turn-stream capability metadata and richer normalized provider events for reasoning blocks, tool lifecycle, and turn boundaries. Track those signals in turn execution state and cover the new Copilot adapter/state behavior with tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 15:23:47 +02:00
David KayaandCopilot fde82bef4d fix: rewrite Quick Prompt event handling with message-by-ID tracking
The sidecar's fire-and-forget event handler pattern
(invokeRunTurnHandler) causes events to arrive out of order:
message-complete and status:idle can arrive before all
message-delta events have been emitted. Additionally, deltas
from multiple messages (thinking, response, sub-agent) were
naively concatenated into a single string, producing garbled
output.

Replace the single-string accumulation with a proper message
tracking model (Map<messageId, TrackedMessage>) that mirrors the
main app's SessionRecord.messages approach:

- Track each message independently by messageId
- Use message-complete event's content field as the authoritative
  final text, replacing any garbled intermediate streaming state
- Mark messages as finalized on message-complete to skip late
  arriving deltas that would corrupt the final content
- Derive display from the last non-thinking message's content
- Only transition to 'complete' when all tracked messages are
  not-pending AND at least one non-thinking message has content,
  preventing premature completion from setup/init events

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 14:39:41 +02:00
David KayaandCopilot 6ef44c6689 fix: prevent premature Quick Prompt completion from setup events
The session emits completion signals (agent-activity:completed,
message-complete, status:idle) during initialization before any
response content arrives. These early events were incorrectly
transitioning the popup to 'complete' phase, causing the
Nothing → In Progress → Nothing cycling pattern.

Track whether response content has been received via a ref and
only transition to 'complete' when hasContentRef is true. This
ensures setup/init completion events are ignored and the popup
stays in streaming until actual content has arrived and a
terminal event follows.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 14:14:25 +02:00
David KayaandCopilot 872476b2e3 fix: detect Quick Prompt completion from all terminal events
The popup stayed in 'streaming' phase because it only transitioned
to 'complete' on message-complete or status:idle when prev was
exactly 'streaming'. If other events (agent-activity, run-updated)
arrived between the last delta and idle, the phase guard could miss.

Broaden completion detection:
- status:idle now completes from any non-idle/non-complete phase
- agent-activity:completed also triggers completion
- Covers all paths the sidecar uses to signal turn end

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 12:45:40 +02:00
David KayaandCopilot b02a90a15f fix: sync Quick Prompt popup theme with main app
The popup window has its own document and never received the
data-theme attribute. Pass the current theme setting from the main
process via the show event, resolve 'system' to effective dark/light,
and apply to document.documentElement.dataset.theme so all CSS
variables match the main app's appearance.

- Add getCurrentTheme() to AryxAppService
- Pass theme in toggleQuickPromptWindow / showQuickPromptWindow
- Update onShow listener signature to receive theme string
- Apply theme to documentElement on each popup activation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 12:36:35 +02:00
David KayaandCopilot d3fb0a64c5 fix: show default model selected when opening Quick Prompt
When no explicit default model is configured in settings, fall back
to the first available model so the input bar always displays a
selected model name instead of 'Select model'. Also always reset
model/reasoning to defaults on each new prompt activation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 12:30:26 +02:00
David KayaandCopilot efcee8a621 fix: group models by provider and eliminate popup background rectangle
- Rewrite ModelSelector to group models by AI provider (OpenAI,
  Anthropic, Google) using providerMeta and ProviderIcon instead of
  grouping by tier; show tier as compact badge per model row
- Remove all box-shadow from qp-panel and border state variants;
  on Windows transparent BrowserWindows, shadows paint on the
  transparent canvas creating a visible dark rectangle behind the
  rounded panel
- Add transparent background to html element in quickprompt.html
  and CSS override via :has(body[data-quickprompt]) to ensure no
  ancestor paints a visible background

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 12:26:02 +02:00
David KayaandCopilot 543cf677ab fix: resolve Quick Prompt popup visibility and model picker issues
- Replace translucent glass background with solid surface-1 color;
  backdrop-filter does not work with transparent BrowserWindows on
  Windows, leaving the panel nearly invisible
- Add data-quickprompt body attribute to quickprompt.html and CSS
  overrides to ensure body/root are truly transparent, eliminating
  the visible rectangle behind the rounded panel
- Remove overflow-hidden from qp-panel so model selector dropdown
  is no longer clipped; the dropdown was positioned absolute and
  rendered entirely outside the panel bounds
- Change ModelSelector to open downward (top-full) instead of upward
  (bottom-full) for natural command-bar flow
- Update dropdown animation direction to match downward opening
- Increase blur handler delay to 200ms for safer dropdown interactions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 12:20:17 +02:00
David KayaandCopilot 952e69da9f fix: improve Quick Prompt popup UX and settings design
- Fix window lifecycle: await renderer load before showing, debounce
  blur handler to prevent dropdown-induced dismiss
- Increase window height from 72px to 520px for response display
- Rewrite ModelSelector with tier-grouped upward-opening dropdown,
  reasoning effort controls, and Brain icon for reasoning models
- Rewrite QuickPromptInput with visible model trigger, animated
  chevron, auto-resize textarea, and proper stop button
- Improve QuickPromptResponse with compact thinking block, markdown
  code/pre/link styling, and refined spacing
- Polish QuickPromptActions with consistent sizing and hover states
- Redesign settings QuickPromptSettingsSection: replace flat radio
  list with compact grouped dropdown selector, only show reasoning
  effort when selected model supports it, combine hotkey toggle
  into single row
- Add markdown code block, inline code, and link CSS styles
- Fix dropdown animation direction for upward-opening selector
- Handle message-complete event in QuickPromptApp

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 12:14:29 +02:00
David KayaandCopilot fb5970cfa7 fix: resolve hotkey registration failures and renderer crash
- Change default hotkey from Super+Shift+A to Alt+Shift+A — Win key
  combinations are frequently reserved by the OS and fail to register
- Make quick prompt window creation lazy (first hotkey press) so it
  cannot interfere with main window startup
- Track failed accelerators to prevent error log spam from repeated
  workspace-updated events
- Fix process.platform crash in renderer SettingsPanel — use isMac
  from @renderer/lib/platform instead (renderer has no node globals)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 11:54:10 +02:00
David KayaandCopilot 6714b93bed fix: resolve Quick Prompt hotkey registration and startup resilience
- Fix accelerator: convert 'Super' to 'Meta' in toElectronAccelerator()
  since Electron uses 'Meta' for the Win/Cmd key, not 'Super'
- Defer quick prompt window creation until after workspace loads so a
  failure cannot block the main window from rendering
- Extract registerQuickPromptIpcHandlers into a separate function called
  after the quick prompt window is created
- Wrap quick prompt window creation in try/catch for resilience

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 11:38:26 +02:00
David KayaandCopilot b038019954 feat: add Quick Prompt global hotkey popup for one-off AI questions
Add a system-wide global hotkey (Win+Shift+A / Cmd+Shift+A) that summons
a floating, frameless popup window for quick AI interactions from any app.

Main process:
- GlobalHotkeyService for registering/unregistering system-wide shortcuts
- Frameless, transparent, always-on-top BrowserWindow factory
- IPC handlers for send, discard, close, continue-in-aryx, cancel
- Session event routing from sidecar to quick prompt window
- Settings persistence for default model, hotkey, and reasoning effort

Renderer (separate lightweight entry):
- QuickPromptApp with session state, streaming, keyboard shortcuts
- QuickPromptInput with model selector trigger and cancel support
- QuickPromptResponse with streamed markdown and thinking blocks
- QuickPromptActions (Discard / Close / Continue in Aryx)
- ModelSelector dropdown with tier badges and reasoning effort
- Glass Command Bar aesthetic with animated gradient border

Settings:
- Quick Prompt section in SettingsPanel with enable toggle, hotkey
  display, default model selector, and reasoning effort picker

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 11:23:14 +02:00
David KayaandCopilot ec92b61663 fix: handle async FSWatcher errors in project customization watcher
On Linux, fs.watch() on a non-existent directory emits an asynchronous
'error' event instead of throwing synchronously. Without an error handler,
this crashes the process. Tests that create AryxAppService with fake
project paths (e.g. C:\workspace\project-one) trigger this on Linux CI
because those paths don't exist.

Attach an 'error' event handler to the FSWatcher that logs a warning and
closes the watcher gracefully. This fixes the Linux CI 'bun test' failure
and also prevents production crashes if a watched directory is deleted
at runtime.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 09:24:24 +02:00
David Kaya 524380e2b5 chore: bump version to 0.0.24 2026-04-13 09:02:20 +02:00
David Kaya 5324a5121d Merge branch 'agents/vital-hyena'
# Conflicts:
#	src/renderer/App.tsx
2026-04-13 08:57:29 +02:00
David KayaandCopilot e3660254df perf: improve application startup performance
Renderer code splitting:
- Lazy-load 15 components with React.lazy() and Suspense (ChatPane,
  ActivityPanel, SettingsPanel, TerminalPanel, WelcomePane, BottomPanel,
  GitPanel, CommandPalette, CommitComposer, WorkflowPicker, BookmarksPanel,
  SessionSearchPanel, KeyboardShortcutsPanel, DiscoveredToolingModal,
  ProjectSettingsPanel)
- This moves ~1.2 MB of optional dependencies (Lexical, @xyflow/react,
  @xterm/xterm, highlight.js, motion) out of the critical bundle
- Critical bundle reduced from ~1.5 MB to ~759 KB
- Defer JetBrains Mono font load to TerminalPanel (saves ~80 KB at startup)

Main process optimizations:
- Use show: false + ready-to-show on BrowserWindow to eliminate blank flash
- Decouple workspace loading from bootstrap — no longer blocks window, tray,
  and auto-update setup
- Defer sidecar-dependent approval tool pruning to run in background after
  workspace is returned to renderer (removes sidecar spawn from critical path)
- Parallelize independent workspace sync operations (user tooling, project
  tooling, project customization) with Promise.all()

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 08:52:45 +02:00
David KayaandCopilot e36b00ff1d fix: make sub-workflow resolution tolerant of missing references
Extract TryResolveSubWorkflowDefinition that returns null instead of
throwing when a referenced workflow is missing from the library.

Use the tolerant variant in all graph-walking helpers that build
agent indexes, collect agent nodes, and find sub-workflow nodes.
These operations are used for activity UI enrichment and agent
identity resolution — they should degrade gracefully rather than
crash the turn.

The throwing ResolveSubWorkflowDefinition is now only used by
WorkflowRunner.CreateNodeRoute, which is the actual execution
path and correctly fails with a clear error if a reference is
truly missing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-08 19:15:58 +02:00
David KayaandCopilot c70a5c6612 feat: show sub-workflow agents and lifecycle in the Activity panel
Deep agent resolution in the sidecar now walks sub-workflow nodes so
nested agents carry subworkflowNodeId and subworkflowName on activity
events. New subworkflow-started / subworkflow-completed activity types
let the frontend track sub-workflow lifecycle.

The Activity panel groups nested agents under collapsible sub-workflow
cards with status badges, accent-colored left borders, and smooth
expand/collapse transitions. Cards auto-expand when a sub-workflow
starts running. Workflows without sub-workflow nodes render identically
to before.

Extracted AgentRow, SubWorkflowGroup, and shared accent constants to
a new components/activity/ feature directory. Added
resolveWorkflowAgentHierarchy and buildGroupedActivityRows for
hierarchical activity grouping with dynamic fallback for unresolved
sub-workflow agents.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-08 18:57:23 +02:00
David KayaandCopilot fa8f6ef4b3 fix: preserve toolArguments through normalization and activity state
- Add toolArguments to normalizeRunTimelineEvent output so persisted
  sessions retain tool argument data across app restarts
- Add toolArguments field to AgentActivityState and propagate it in
  applySessionEventActivity for real-time activity labels
- Add tests for normalization round-trip with and without toolArguments
- Add test for activity state including toolArguments

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-08 12:55:03 +02:00
David KayaandCopilot b9e73831e8 fix: enrich tool activity arguments on dedup
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-08 12:14:03 +02:00
David KayaandCopilot 0e2f9b8ae5 refactor: generalize sidecar workflow runner
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-08 11:35:50 +02:00
David KayaandCopilot e46193ae53 refactor: extract copilot provider module
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-08 11:27:58 +02:00
David KayaandCopilot 008d8c1bd0 refactor: add provider abstraction to sidecar host
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-08 11:18:20 +02:00
David KayaandCopilot e85906669f refactor: decouple turn execution state from Copilot SDK events
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-08 11:12:40 +02:00
David KayaandCopilot 2c165e453f fix: extract tool arguments from ToolExecutionStartEvent in sidecar
The CopilotTurnExecutionState was ignoring ToolExecutionStartData.Arguments
when creating tool-calling activity events, despite the Copilot SDK providing
them. This caused the activity panel to show 'Viewed a file' / 'Used rg'
with no contextual details (file paths, search patterns, etc.).

The fix adds NormalizeRawToolArguments to WorkflowRequestInfoInterpreter for
converting the raw object? payload (typically JsonElement) into the normalized
dictionary, then passes ToolExecutionStartData.Arguments through
CreateToolCallingActivity.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-08 10:47:55 +02:00
David KayaandCopilot 931ec27f42 fix: use fallback args when tool-specific keys are missing in labels
When toolArguments doesn't contain the expected key (e.g. 'path' for
view), fall back to the first usable string value from any argument
key instead of showing misleading placeholders like 'Viewed a file'.
When no arguments exist at all, show just the tool name.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-08 10:14:29 +02:00
David KayaandCopilot 3e71de98e8 feat: redesign activity panel UX with grouped timeline and verb-based labels
Replace the flat, repetitive 'Agent used X' activity list with a
structured narrative timeline:

- Group consecutive same-tool calls into collapsible rows with context
  (e.g. 'Viewed 4 files' with file names listed below)
- Use verb-based labels with arguments: 'Viewed ChatPane.tsx:148-250',
  'Searched for tool-call', 'Edited runTimeline.ts'
- Promote report_intent events to phase dividers that segment the
  timeline into labeled stages of work
- Show latest intent text in the collapsed header summary
- Use category-specific icons (Eye, Search, Pencil, Terminal, etc.)
  instead of the universal wrench
- Render thinking steps as quoted blocks with 'N more' toggle for
  consecutive groups

All data was already available in RunTimelineEventRecord.toolArguments;
this change surfaces it prominently instead of hiding it behind
expandable detail panels.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-08 09:51:34 +02:00
David KayaandCopilot 6d698ca233 chore: update sidecar nuget packages
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-08 09:16:47 +02:00
David KayaandCopilot 574455729b refactor: extract AryxAppService into focused service delegates
Extract seven responsibility clusters from the 3,466-line AryxAppService
monolith into focused service classes under src/main/services/:

- WorkflowManager: workflow CRUD, templates, validation, resolution
- McpProbeManager: MCP server probing, OAuth pre-auth, probe queuing
- DiscoveredToolingSyncService: tooling/customization scanning, watchers
- GitContextManager: git refresh orchestration, context updates, mutations
- CheckpointRecoveryManager: checkpoint retry/recovery state handling
- ApprovalCoordinator: approval/user-input/plan-review/OAuth state machine
- SessionTurnExecutor: turn execution, streaming deltas, finalization

AryxAppService remains the public facade consumed by IPC handlers but now
delegates internally via constructor-injected service instances. A new
AppServiceDeps type provides a clean dependency injection seam for testing.

The facade is reduced from 3,466 to 2,572 lines. All existing tests pass
with two test files migrated to constructor DI (appServiceGitRefresh,
appServiceMcpProbing). New focused tests added for WorkflowManager and
the DI seam itself.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 22:18:52 +02:00
David Kaya 36b8dd0c12 chore: bump version to 0.0.23 2026-04-07 19:15:40 +02:00
David KayaandCopilot 7a7b3fcdca fix: defer Copilot CLI resolution until agent nodes exist
CopilotAgentBundle.CreateAsync unconditionally resolved the Copilot CLI
path and started a CopilotClient, even for workflows with no agent
nodes (e.g. request-port-only workflows). This caused a hard failure
in CI where the copilot binary is not on PATH.

Move CLI path resolution, client creation, and agent setup inside a
guard that checks whether the workflow actually contains agent nodes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 19:14:56 +02:00
David KayaandCopilot 794794afe4 fix(ci): update .NET SDK version to 10.0.x to match sidecar target framework
The sidecar projects were updated to target net10.0 but the CI
workflow still installed dotnet-version 9.0.x, causing all validate
jobs to fail at the sidecar test step.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 18:54:03 +02:00
David Kaya 9f85cce418 chore: bump version to 0.0.22 2026-04-07 18:21:25 +02:00
David Kaya 33773c868f Merge branch 'copilot/amused-horse' 2026-04-07 17:54:46 +02:00
David KayaandCopilot 507bd5408c feat: show expandable tool call details in chat activity panel
Thread toolArguments from the sidecar through shared contracts, main-process pipeline, and into the renderer. Add ToolCallDetailPanel with inline summaries (command, path, pattern, etc.) and expandable argument details for every tool-call event in the activity timeline.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 14:58:22 +02:00
David KayaandCopilot 7e37d75116 fix: emit completed activity for sequential agents
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 14:53:08 +02:00
David KayaandCopilot fd9cc03c5c feat: capture tool call arguments in sidecar activity events
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 14:50:22 +02:00
David KayaandCopilot 8797186a73 feat(website): add screenshot lightbox and update orchestration screenshots
Add click-to-enlarge lightbox for both hero and orchestration
screenshots. Clicking opens a fullscreen overlay with smooth scale
animation; clicking the backdrop, close button, or pressing Escape
dismisses it. Screenshots are keyboard-accessible (Enter/Space) and
the overlay uses role=dialog with aria-modal for screen readers.

Update orchestration screenshots (dark + light) with latest captures
showing the workflow designer.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 14:20:27 +02:00
David KayaandCopilot 573efff647 docs(website): rename patterns to workflows and add Workflow Designer features
Update all 13 references from 'patterns' to 'workflows' across the
product website to reflect the patterns-to-workflows migration.

Terminology changes:
- Navigation links: Patterns -> Workflows (#patterns -> #workflows)
- Hero, feature cards, section headings, tech banner, get-started steps
- 'Visual Pattern Editor' -> 'Workflow Designer'
- 'Run Timeline' -> 'Turn Activity Panels' (inline in chat turns)

New Workflow Designer section highlighting shipped capabilities:
- Visual Graph Editor with interactive canvas
- Eight rich hand-crafted workflow templates
- Conditional edges and loop iteration controls
- Sub-workflow nesting with breadcrumb navigation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 14:15:54 +02:00
David Kaya b583f39a38 Merge branch 'copilot/recent-swordtail' 2026-04-07 12:30:44 +02:00
David KayaandCopilot 3a639813a9 fix: resolve sub-workflow agent discovery
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 12:28:51 +02:00
David KayaandCopilot 0a5fa81111 fix: scope turn-activity panel metrics to per-agent events in sequential workflows
In sequential workflows, multiple collapsible turn-activity panels shared
the same SessionRunRecord and displayed identical aggregate metrics (tool
calls, approvals, etc.) instead of per-agent counts.

Root cause: ChatPane grouped thinking messages correctly per agent, but
passed the full unfiltered run object to every TurnActivityPanel, so
summarizeActivity() counted all events from the entire run for each panel.

Changes:
- Extract filterEventsByAgent() and summarizeActivity() to
  runTimelineFormatting.ts as pure, testable helpers
- Add agentNames (derived from thinking message authors) and isLastRunPanel
  to the DisplayItem turn-activity variant in ChatPane
- TurnActivityPanel now filters run.events through filterEventsByAgent()
  so each panel only counts events belonging to its agents
- Git summary / discard actions only render on the last panel of a run
- Per-agent timing derived from scoped events instead of run-level start

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 12:25:01 +02:00
David KayaandCopilot 778c3b4a5b fix: synchronize workflow iteration settings across edges, mode settings, and scaffold
For builder-based orchestration modes (group-chat, handoff), the loop edge
maxIterations, workflow settings.maxIterations, and mode-specific settings
(e.g. groupChat.maxRounds) were independently editable but only the mode
settings controlled runtime behavior. This caused confusion when changing
one did not update the others.

Changes:
- Add syncBuilderModeEdgeIterations() to keep loop edge maxIterations in
  sync with the authoritative mode settings for builder-based modes
- Wire sync into normalizeWorkflowDefinition so edges are consistent on
  load/normalization
- Update scaffoldGraphForMode to accept optional settings instead of
  hardcoded iteration values (4 for handoff, 5 for group-chat)
- Sync settings.maxIterations <-> groupChat.maxRounds bidirectionally
  when either changes in the UI
- Make loop edge controls read-only in the graph inspector for
  builder-based modes with explanatory text
- Add 8 consistency tests validating built-in workflows, scaffold
  behavior, and normalization sync

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 11:04:38 +02:00
David KayaandCopilot 366200b29d fix: eliminate thinking message flash before reclassification
The optimistic fold previously required prior thinking messages in the
pending group (pendingThinking.length > 0) before it would absorb an
unclassified pending assistant message. When the first assistant message
in a turn arrived before any thinking messages were classified, it
briefly rendered as a full chat message, then snapped into the
collapsible panel once the message-reclassified event arrived.

Remove the pendingThinking.length guard so any trailing pending,
unclassified assistant message during an active turn is folded into
the turn-activity panel immediately, regardless of whether prior
thinking messages exist.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 10:25:09 +02:00
David KayaandCopilot cbc05c8f08 fix: allow 'always' condition on loop edges when maxIterations is set
The WorkflowValidator rejected loop edges with an 'always' condition,
but maxIterations already guarantees termination at runtime (enforced by
WorkflowConditionEvaluator). This made built-in templates like
Collaborative Group Chat fail validation immediately.

Relax the check so 'always' conditions are accepted when maxIterations
provides the termination guarantee. The condition error now only fires
when neither a non-default condition nor a maxIterations cap is present.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 10:11:49 +02:00
David KayaandCopilot ecba1be92a fix: show turn activity panel immediately when run starts
The panel was only emitted as a DisplayItem when classified thinking
messages existed. During most of the turn, thinking messages haven't
been reclassified yet, so the panel appeared late with all activities
already populated.

Two fixes:
- After the message loop, inject a turn-activity item for any active
  run that wasn't already attached to a thinking group. This makes the
  panel appear as soon as the run record exists.
- Remove the guard that required run events to exist before rendering.
  The panel now shows in its 'Working' state immediately when the run
  starts, and populates progressively as events arrive.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 10:03:06 +02:00
David KayaandCopilot 3a71baf104 feat: move run timeline from side panel into inline chat turn activity panels
Replace the separate RunTimeline in the ActivityPanel with a generalized
TurnActivityPanel that renders inline in the chat pane between user
messages and assistant responses.

The new component:
- Shows all turn activities (thinking steps, tool calls, approvals,
  handoffs, run status) in a single collapsible panel
- Merges chat thinking messages with run timeline events into a
  chronological activity stream
- Auto-expands when a turn starts, auto-collapses on completion
- Displays a compact summary header (elapsed time, tool call count,
  handoff count, etc.) when collapsed
- Includes post-run git change summary with discard/commit actions
- Uses subtle entrance animations for rows

Removed components:
- ThinkingProcess.tsx (subsumed by TurnActivityPanel)
- RunTimeline.tsx (no longer needed as a standalone component)

The ActivityPanel retains its agents, session usage, and turn events
(diagnostics/hooks) sections.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 09:47:19 +02:00
David KayaandCopilot a3e0bd9244 fix: hide unimplemented telemetry toggles from workflow settings
Remove the OpenTelemetry and Filter Sensitive Data toggles from the
workflow settings panel. The backend does not consume these settings,
so exposing them is misleading. The domain model and DTOs are retained
so existing workflow definitions remain valid.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 09:24:48 +02:00
David KayaandCopilot d265db42e5 feat: add descriptive help text to graph designer properties
Add inline descriptions to workflow settings, orchestration mode,
edge inspector, node inspectors, and condition editor properties
so users understand what each option does at a glance.

- Execution Mode: explains off-thread vs lockstep behavior
- Max Iterations: clarifies it prevents runaway loops
- State Scopes: describes shared state containers
- Edge Kind: explains direct, fan-out, fan-in semantics
- Loop Edge: describes iterative cycle behavior
- Reasoning: explains effort vs answer quality tradeoff
- Triage Agent, Tool-Call Filtering, Max Rounds, etc.
- Result Variable, Require Approval, Port ID, Condition

Also enhances FormField component with optional description prop.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 09:02:15 +02:00
David Kaya ad2eab73d7 Merge branch 'copilot/precious-seahorse' 2026-04-07 08:49:30 +02:00
David KayaandCopilot e79cbe8df9 feat: improve agent status and thinking indicator UX
- Show agent activity label in chat header (Thinking…, Using bash…, etc.)
  instead of an anonymous pulsing dot
- Fix ThinkingProcess elapsed timer to tick live every second using
  useElapsedTimer hook instead of stale useMemo computation
- Show completed/failed subagents for 3s grace period with fade-out
  transition instead of instantly hiding them
- Transition activity labels to Completed state for 1.5s grace period
  on session idle instead of abrupt removal
- Eliminate message flash during thinking reclassification by folding
  trailing pending assistant messages into the thinking group
  optimistically when they follow existing thinking messages
- Stabilize ThinkingProcess isActive flag by checking for pending
  messages in the group rather than relying solely on array position
- Skip rendering empty pending messages inside ThinkingProcess steps

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 08:44:18 +02:00
David KayaandCopilot 1ac1cc1d47 fix: share single CopilotClient across workflow agents
Previously, CopilotAgentBundle.CreateAsync spawned a separate
CopilotClient (and thus a separate Copilot CLI process) for each
agent in a workflow. When multiple CLI processes started concurrently,
their auto-login mechanisms could race on token refresh, causing
subsequent agents to fail with 'Session was not created with
authentication info or custom provider'.

Share one CopilotClient across all agents in the bundle. Each agent
still gets its own CopilotSession on the shared client. The bundle
owns the client and disposes it after all agents.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 08:15:56 +02:00
David KayaandCopilot bf72315735 feat: enable all workflow types for scratchpad sessions
Scratchpad sessions were hardcoded to only use single-mode workflows.
The handleCreateScratchpad handler filtered for orchestrationMode === 'single'
and auto-selected the first match without any user choice.

Now clicking 'New Scratchpad' opens the WorkflowPicker (same as regular
sessions), letting users choose from all available workflows including
sequential, concurrent, handoff, and group-chat. When only one workflow
exists, it still auto-creates for a seamless single-click experience.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 08:00:40 +02:00
David KayaandCopilot 59170a794d feat: add workflow picker for new session creation
When creating a new session with >1 workflow available, a searchable
popover appears letting users choose which workflow to use. Workflows
are grouped by orchestration mode with mode badges, agent counts,
and favorite indicators. Keyboard navigation (↑↓ Enter Escape) and
fuzzy search are supported.

If only one workflow exists, session creation works immediately
without showing the picker. Scratchpad auto-selects as before.

Wired into all session creation paths:
- Sidebar 'New Session' button
- Ctrl/Cmd+N keyboard shortcut
- Command palette 'New Session' action

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-06 23:57:06 +02:00
David KayaandCopilot 3564df78d3 fix: always replace builtin workflows with canonical definitions
The merge logic was preserving stale persisted versions of builtin
workflows, only overlaying orchestrationMode. This meant edge fixes
(like the handoff loop marking) never reached users who already had
the old version saved.

Builtin workflows are now fully replaced on load so fixes propagate
automatically. Custom (non-builtin) workflows are preserved as-is.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-06 23:41:00 +02:00
David KayaandCopilot a68c9ee837 fix: mark handoff triage-to-specialist edges as loop edges
The forward edges from triage to specialists participate in cycles
(triage→specialist + specialist→triage), so they must be marked with
isLoop, condition, and maxIterations to pass validation.

Fixes the Handoff Support Flow template and scaffoldGraphForMode()
for handoff mode. Updates the corresponding test expectation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-06 23:37:34 +02:00
David KayaandCopilot 5ac6980b3f fix: prevent settings panel from collapsing graph canvas
The center column was a flex column with the canvas as flex-1 min-h-0
and the settings panel as shrink-0. When settings content grew tall
(especially with the orchestration mode panel expanded), the canvas
collapsed to zero height.

Switch the center column to a scrollable container with the canvas
having a guaranteed minimum height via clamp(360px, 50vh, 100%).
Settings flow naturally below and the whole column scrolls.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-06 23:33:59 +02:00
David KayaandCopilot f4cb4c768b feat: add frontend orchestration mode UI to workflow designer
- Add OrchestrationModePanel with mode cards for all 5 modes (single,
  sequential, concurrent, handoff, group-chat)
- Add handoff settings panel: triage agent selector, tool-call
  filtering, return-to-previous toggle, custom instructions
- Add group-chat settings panel: selection strategy display, max
  rounds input
- Add auto-inference indicator when mode is not explicitly set
- Add scaffold dialog when changing mode with existing agents
- Add mode badge (top-left pill) on workflow graph canvas
- Wire orchestration mode panel into WorkflowSettingsPanel above
  general settings

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-06 23:30:01 +02:00
David KayaandCopilot e265714225 feat: add backend orchestration mode support
Add first-class backend support for workflow orchestration modes,
including mode settings, graph scaffolding, mode-aware validation,
and real Agent Framework handoff/group-chat execution paths.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-06 23:18:51 +02:00
David KayaandCopilot 805e369b67 refactor: remove legacy patterns system, unify on workflows
Remove the entire patterns domain model, IPC channels, sidecar services,
renderer components, and tests. Sessions now bind exclusively to workflows
via workflowId. Builtin workflows replace builtin patterns.

Backend:
- Make AgentNodeConfig standalone (no longer extends PatternAgentDefinition)
- Add WorkflowOrchestrationMode and WorkflowExecutionDefinition
- Create builtin workflows (single-agent, sequential, concurrent, handoff, group-chat)
- Rewrite session model config helpers for workflow-only
- Remove pattern IPC channels, handlers, and preload bindings
- Merge createSession/createWorkflowSession into single method
- Remove sidecar PatternGraphResolver, PatternValidator, pattern DTOs
- Add workspace migration for legacy sessions (patternId -> workflowId)

Frontend:
- Delete PatternEditor, pattern-graph components, patternGraph lib
- Delete NewSessionModal (session creation uses workflows directly)
- Remove PatternsSection from SettingsPanel
- Update App.tsx, ChatPane, ActivityPanel, Sidebar, RunTimeline,
  AgentConfigFields, InlinePills, sessionActivity to use workflow types
- Delete pattern.ts domain module

78 files changed across backend and frontend.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-06 22:37:00 +02:00
David KayaandCopilot 69d0804161 refactor: remove pattern domain and migrate renderer to workflow-only
- Delete src/shared/domain/pattern.ts and all pattern-specific renderer
  components (PatternEditor, NewSessionModal, pattern-graph directory,
  patternGraph lib) and their tests
- Migrate all renderer imports from @shared/domain/pattern to
  @shared/domain/workflow (ReasoningEffort, reasoningEffortOptions,
  WorkflowOrchestrationMode, AgentNodeConfig, WorkflowDefinition)
- Update App.tsx: remove workflowToPattern bridge, createDraftPattern,
  NewSessionModal; sessions now created directly with default workflow
- Update ChatPane, ActivityPanel, Sidebar to accept WorkflowDefinition
  instead of PatternDefinition and derive agents/mode from workflow
- Update SettingsPanel: remove PatternsSection nav item, pattern editing
  state, and pattern-related props; update text references
- Update RunTimeline and modeAccent/modeVisuals records to use
  WorkflowOrchestrationMode (drop magentic mode entry)
- Update sessionActivity.ts to use generic agent interface

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-06 22:34:42 +02:00
David KayaandCopilot 059714326a fix(security): harden YAML parsing, path traversal, and markdown links
- workflowSerialization: restrict YAML parser to core schema with no custom tags
- gitService: validate file paths stay within project directory before I/O
- chatMarkdown: allowlist URL protocols (https, http, mailto, anchors)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 23:43:06 +02:00
David KayaandCopilot b302ea5979 feat(workflows): replace basic templates with 8 rich hand-crafted templates
Replace auto-generated pattern-based templates with complex, real-world
workflow templates showcasing all node types and edge features:

- Code Review Pipeline: sequential chain with checkpointing
- Research & Summarize: 3 parallel agents via fan-out/fan-in
- Customer Support Triage: conditional routing with expression edges
- Content Creation Pipeline: writer/editor revision loop
- Multi-Agent Debate: proposer/critic debate loop
- Data Processing with Validation: invoke-function nodes, state scopes
- Approval Workflow: request-port for human-in-the-loop review
- Nested Workflow Orchestrator: inline sub-workflow composition

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 22:27:35 +02:00
David KayaandCopilot 81eb8f7c82 refactor(workflows): remove code-executor and align function node with Agent Framework
Remove the invented code-executor node type entirely. Rename
function-executor to invoke-function to match Agent Framework's
InvokeFunctionTool declarative action. Update InvokeFunctionConfig to
use functionName, arguments, requireApproval, and resultVariable
properties matching the upstream schema.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 21:59:16 +02:00
David KayaandCopilot e47835c1e8 fix(workflows): disable start/end palette buttons when node already exists
Prevents users from adding duplicate start or end nodes, which made the
workflow invalid with no way to recover since those node kinds are
protected from deletion.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 21:37:01 +02:00
David KayaandCopilot 55df0c3b61 feat: implement Phase 5 workflow frontend features
- Template Gallery: category-filtered grid in WorkflowsSection with
  template cards showing name, description, category/source badges,
  and node/agent counts with 'Use Template' action
- Export/Import: dropdown export (YAML/Mermaid/DOT) and import
  (YAML/JSON) modals in WorkflowEditor header toolbar via new
  WorkflowExportImportPanel component
- Pattern-to-Workflow upgrade: ArrowUpRight icon button on each
  pattern row in PatternsSection calling upgradePatternToWorkflow
- Canvas polish: zoom in/out/fit-view controls panel at bottom-right
  of WorkflowGraphCanvas, zoom percentage display, Ctrl+A select-all
  keyboard shortcut, and selectionOnDrag enabled

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 21:28:43 +02:00
David KayaandCopilot 4971dcf9fc feat(workflows): add phase 5 backend templates and export support
- add shared workflow template and serialization helpers for YAML, Mermaid, and DOT export
- add pattern-to-workflow upgrade and template application flows in AryxAppService
- persist built-in and custom workflow templates in workspace state and expose new IPC/preload methods
- cover the new backend slice with shared and app-service tests

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 21:11:59 +02:00
David KayaandCopilot f05ec8ac7f feat: add code-executor, function-executor, request-port inspectors and enhanced workflow settings
- Create CodeExecutorInspector with implementation directive editor,
  input/output type fields, and directive help callout
- Create FunctionExecutorInspector with built-in function selector
  and key-value parameter editor
- Create RequestPortInspector with port ID, request/response type
  fields, prompt editor, and info callout
- Wire all three inspectors into WorkflowGraphInspector routing,
  keeping PlaceholderNodeInspector as fallback for unknown kinds
- Enhance WorkflowSettingsPanel with OpenTelemetry and sensitive
  data filtering toggles, and a state scopes editor with initial
  values support

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 20:36:39 +02:00
David KayaandCopilot 69ac454f29 feat(workflows): add phase 4 backend executor support
Add MVP code-executor and function-executor runtime support, request-port
bridging through Aryx user input, state-scope helpers, lockstep execution
mode handling, and validation/tests for new workflow node kinds.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 20:22:20 +02:00
David KayaandCopilot 7a32c9c0a3 fix(workflows): remove stale Phase 2 placeholder text from node inspector
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 19:53:20 +02:00
David KayaandCopilot 871609db5b feat(workflows): add sub-workflow inspector, breadcrumb navigation, and reference UI
Phase 3 frontend: sub-workflow node inspector with reference/inline mode toggle,
breadcrumb drill-down navigation for nested workflows, read-only view for
referenced workflows, inline workflow editing with stack propagation,
sub-workflow label badges on canvas nodes, and referential integrity error
display on delete.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 19:44:02 +02:00
David KayaandCopilot 41e74c2fa9 feat(workflows): add sub-workflow backend support
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 19:31:56 +02:00
David KayaandCopilot ea9444ddac feat(workflows): add condition editor, loop controls, and edge visualization
Phase 2 frontend: visual condition editor with property rule builder, expression
input, message-type selector, loop toggle with max iterations, self-loop edge
rendering, conditional/loop edge badges, and per-edge validation display.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 19:08:11 +02:00
David KayaandCopilot 8f6830dca1 feat(workflows): add conditional edges and loop validation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 18:57:33 +02:00
David KayaandCopilot f011311514 feat: add Workflow Designer frontend (Phase 1)
Implement the visual workflow editor UI with ReactFlow canvas,
node palette, inspector panel, and full SettingsPanel integration.

New files:
- workflowGraph.ts: graph utilities (layout, connection rules, edges)
- WorkflowGraphNodes.tsx: custom ReactFlow nodes per workflow kind
- WorkflowGraphCanvas.tsx: ReactFlow canvas with drag, connect, layout
- WorkflowGraphInspector.tsx: node/edge inspector with agent config
- WorkflowNodePalette.tsx: categorized node palette for adding nodes
- WorkflowEditor.tsx: full-screen editor with validation and settings

Modified files:
- SettingsPanel.tsx: add workflows section, nav item, editing state
- App.tsx: wire workflow CRUD props and createDraftWorkflow factory

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 17:53:33 +02:00
David KayaandCopilot 19a764d297 feat(workflows): Phase 1 backend — WorkflowDefinition model, persistence, IPC, sidecar execution
Introduce WorkflowDefinition as a first-class entity alongside PatternDefinition.

Shared domain:
- src/shared/domain/workflow.ts: new WorkflowDefinition, WorkflowGraph,
  WorkflowNode/Edge/Config types, normalization, TS-side validation, and
  buildWorkflowExecutionPattern (synthetic pattern bridge for session plumbing)
- workspace.ts: add workflows[] and selectedWorkflowId
- session.ts: add optional workflowId to SessionRecord
- runTimeline.ts: add optional workflowId/workflowName to run records
- sessionLibrary.ts: workflow-aware session search

IPC / main process:
- contracts/channels.ts, contracts/ipc.ts: workflow CRUD channels
- preload/index.ts: expose saveWorkflow, deleteWorkflow, createWorkflowSession
- ipc/registerIpcHandlers.ts: register workflow handlers
- persistence/workspaceRepository.ts: load/save/normalize workflows
- AryxAppService.ts: saveWorkflow, deleteWorkflow, createWorkflowSession,
  resolveSessionExecutionDefinition, workflow-aware turn/run plumbing

Sidecar protocol:
- contracts/sidecar.ts: ValidateWorkflowCommand, WorkflowValidationEvent,
  optional workflow on RunTurnCommand
- sidecarProcess.ts: validate-workflow command dispatch and event handling
- Contracts/ProtocolModels.cs: workflow DTOs and validate-workflow DTOs
- Services/SidecarProtocolHost.cs: validate-workflow command handler
- Services/CopilotWorkflowRunner.cs: workflow-aware build and checkpoint path
- Services/WorkflowValidator.cs: graph validation (connectivity, start/end,
  fan-out/in arity, path reachability)
- Services/WorkflowRunner.cs: WorkflowDefinitionDto -> Agent Framework Workflow
  builder (direct, fan-out, fan-in edges; agent executor binding)

Project: bump both csproj from net9.0 to net10.0

Tests:
- tests/shared/workflow.test.ts: TS workflow validation and pattern synthesis
- tests/main/appServiceWorkflow.test.ts: saveWorkflow / createWorkflowSession
- tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs: ValidateWorkflowCommand
- Fix two stale workspace fixtures missing workflows field

Validation: tsc clean, bun test 367/367 pass, dotnet test 244/244 pass, bun run build succeeds.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 17:29:03 +02:00
David KayaandCopilot 4c3198a550 refactor: rename Orchestration to Workflows in settings nav
Align settings terminology with Agent Framework docs where 'Workflows'
is the top-level concept. 'Orchestration' now only appears as
'Orchestration Mode' in the pattern editor, correctly referring to the
specific mode (sequential, concurrent, etc.) within a workflow.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-02 17:39:41 +02:00
David KayaandCopilot c0aaffa39f feat: allow deletion of built-in orchestration patterns
Remove the restriction that prevented users from deleting built-in
patterns (those with IDs prefixed 'pattern-'). Built-in patterns are
intended as starting points, not permanent fixtures.

Changes:
- Add deletedBuiltinPatternIds field to WorkspaceState to track which
  built-in patterns the user has removed
- Update mergePatterns to skip deleted built-ins during workspace load,
  preventing them from being re-added
- Remove the isBuiltinPattern guard from AryxAppService.deletePattern;
  when a built-in is deleted, its ID is recorded in the tracking list
- Enable the delete button in SettingsPanel and PatternEditor for all
  patterns (keep the 'Built-in pattern' label for context)
- Add 3 tests covering built-in deletion, custom deletion, and
  selectedPatternId fallback

Users can still restore deleted built-ins via workspace reset.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-02 17:31:49 +02:00
David KayaandCopilot e39fffaf3b feat: add workspace agents for reusable agent definitions across patterns
Introduce a workspace-level agent library that allows users to define
agents once and reference them from multiple orchestration patterns.

Domain model:
- Add WorkspaceAgentDefinition type with name, model, instructions, etc.
- Extend PatternAgentDefinition with optional workspaceAgentId and overrides
- Add resolution helpers that merge workspace agent base with per-pattern overrides
- Extend WorkspaceSettings with agents array and normalize on load

IPC & main process:
- Add saveWorkspaceAgent/deleteWorkspaceAgent IPC channels and handlers
- Resolve workspace agent references in buildEffectivePattern before
  sending to sidecar (no C# changes needed)

Settings UI:
- Add 'Agents' tab under Orchestration in the Settings panel
- Create WorkspaceAgentEditor component using ToolingEditorShell
- Show usage count (which patterns reference each agent)

Pattern editor integration:
- Add agent picker dropdown: 'New inline agent' or 'From library'
- Show linked badge (chain icon) on referenced agent graph nodes
- Show linked workspace agent banner in the inspector
- Add 'Save to Agent Library' action to promote inline agents
- Add 'Unlink' action to convert referenced agents back to inline

Tests:
- Add unit tests for resolution helpers (resolvePatternAgent,
  resolvePatternAgents, findWorkspaceAgentUsages, normalize)
- Update existing tooling test for new agents field

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-02 17:09:54 +02:00
David KayaandCopilot 10316a2871 fix: strip reasoning effort on prompt model override, show actual model badge
When a prompt file overrides the turn model, the original agent's
reasoning effort was carried through even when the target model does
not support it. This caused session.create failures for models like
Claude Opus 4.5 which reject reasoning effort configuration.

Now: if the target model's reasoning capabilities are unknown
(supportedReasoningEfforts undefined) or it's an unresolved model
reference, reasoning effort is stripped entirely.

Also adds a model override badge on assistant messages when the run
used a different model than the session's primary agent. The badge
reads the actual model from the run record (SessionRunRecord.agents)
rather than inferring from the prompt invocation metadata.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-02 16:30:32 +02:00
David KayaandCopilot a18803758b feat: phase 3 frontend — model badges, argument-hint composer, ancestor path display
Surface prompt model and argumentHint metadata across the UI:

- InlinePromptPill: send model in promptInvocation, show model badge
  in dropdown, show argumentHint preview, support armed-prompt state
  for prompts with argumentHint (prompt stays pinned, hint becomes
  composer placeholder, user types additional context before sending)
- ChatPane: armed-prompt state management, argumentHint-driven
  placeholder, send button enabled when prompt is armed, clear armed
  state on session change
- PromptInvocationChrome: model badge in transcript, parent-repo badge
  and shortened source path display for ancestor-relative paths
- ProjectSettingsPanel: model and argumentHint on prompt cards,
  parent-repo badge on instructions and prompts with ancestor paths,
  .claude/rules mentioned in instructions description and empty state

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-02 12:53:38 +02:00
David KayaandCopilot 6eadb36c10 feat: add phase 3 prompt customization backend
- parse prompt model and argument-hint metadata and persist model on prompt invocations
- expand markdown-linked file context in scanned prompt and instruction bodies
- discover .claude/rules instructions and support Claude-style paths metadata
- discover customization roots up to the nearest parent repository and watch them for changes
- apply per-turn prompt model overrides before sidecar execution

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-02 12:21:59 +02:00
David KayaandCopilot bcbdd2ef29 feat: wire structured prompt invocation through renderer
- Update InlinePromptPill to build ProjectPromptInvocation from
  selected prompt file and send it via onSubmit instead of pasting
  raw resolved text as chat content
- Thread promptInvocation through ChatPane.onSend and App.tsx to
  the sendSessionMessage IPC call
- Add PromptInvocationChrome component for user messages that were
  triggered by a prompt file, showing prompt name, agent badge,
  tool count, source path, and expandable resolved body
- Surface prompt agent and tools metadata in InlinePromptPill
  dropdown list items and ProjectSettingsPanel PromptCard

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-02 11:49:45 +02:00
David KayaandCopilot 5d69d9d855 feat: add structured prompt invocation backend
- parse prompt tools metadata and carry structured promptInvocation payloads
- store prompt invocation metadata on trigger messages for replay-safe reruns
- route prompt agents through per-turn plan or Copilot agent overrides
- restrict prompt-scoped tools in sidecar session configuration
- auto-rescan project customization files with debounced watchers
- document the new customization watcher and prompt invocation flow

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-02 11:41:18 +02:00
David KayaandCopilot aa7830f01a feat: update project settings UI for expanded instruction discovery
- Show application mode badges (always/file/task/manual) on instruction cards
- Display instruction name, description, and applyTo glob when present
- Update section descriptions to reflect recursive scanning paths
- Update empty states to list all discovery locations
- Add InstructionModeBadge component for visual mode indicators

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-02 11:21:10 +02:00
David KayaandCopilot 36b37e8915 feat: expand repo customization discovery
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-02 11:14:39 +02:00
David Kaya dc69f8bf04 chore: bump version to 0.0.21 2026-04-01 20:57:05 +02:00
David KayaandCopilot 6f7cf60aa9 fix: render pending messages with markdown formatting
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-01 20:12:59 +02:00
David KayaandCopilot 423d45fa1b fix: use active agent for streaming author fallback
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-01 19:40:46 +02:00
David KayaandCopilot 13bcc44f1a feat: add handoff workflow checkpoint recovery
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-01 19:28:05 +02:00
David KayaandCopilot 1ceb3d5669 fix: project copilot tool results into workflows
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-01 19:17:47 +02:00
David KayaandCopilot 1dd13588a0 refactor: move handoff guidance into workflow builder
Move handoff routing and ownership rules out of per-agent system
prompts and into the Agent Framework handoff builder guidance.

This keeps AgentInstructionComposer focused on Aryx-owned system
prompt content while letting WithHandoffInstructions supply the
workflow-level handoff semantics.

- remove handoff-mode runtime guidance from AgentInstructionComposer
- expand HandoffWorkflowGuidance with the triage/specialist rules
- update tests to pin the new split of responsibilities

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-01 19:05:01 +02:00
David KayaandCopilot 235ddf7e56 refactor: pin workflow host options explicitly
Explicitly configure AIAgentHostOptions for the workflow modes that
host agents directly instead of relying on Agent Framework defaults.

- add a shared host-options factory that preserves Aryx's current
  behavior
- use custom sequential, concurrent, and round-robin group-chat
  builders so all host options are set intentionally
- add workflow-level tests asserting the configured host options

Keep EmitAgentResponseEvents disabled because Aryx still projects
streaming transcript state itself and enabling response events would
require a separate reconciliation change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-01 19:04:44 +02:00
David KayaandCopilot d7004ec2a9 fix: make handoff filtering explicit
Explicitly configure handoff workflows to use
HandoffToolCallFilteringBehavior.HandoffOnly instead of relying on
Agent Framework's current default.

This keeps normal tool-call history visible across handoffs while
still stripping handoff plumbing, and adds a regression test to pin
that behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-01 19:04:23 +02:00
David KayaandCopilot 0aed6240b9 feat: render workflow diagnostics in activity panel
Surface workflow-diagnostic session events (warnings and errors from
Agent Framework workflows) in the turn-event log of the Activity Panel.

- Add workflow-diagnostic case to formatTurnEventEntry with label/detail
  formatting that includes executor ID, subworkflow ID, exception type,
  and diagnostic message when present
- Add AlertTriangle icon for diagnostic events with error/warning color
- Add 5 tests covering executor-failed, workflow-warning, subworkflow-error,
  workflow-error, and missing diagnosticKind fallback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-01 18:54:21 +02:00
David KayaandCopilot 11b36827f5 feat: surface workflow diagnostics
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-01 18:50:56 +02:00
David Kaya b434dd86b4 chore: bump version to 0.0.20 2026-03-31 17:10:47 +02:00
David Kaya c2a691774a feat: add light mode screenshots 2026-03-31 17:04:35 +01:00
David KayaandCopilot 78949c5efd feat: render per-turn thinking tiles in chat pane
Replace the single aggregate thinking tile with per-turn thinking groups.
Instead of collecting all thinking messages into one flat array and
rendering a single ThinkingProcess before the last assistant message,
process session.messages in chronological order to produce interleaved
display items that naturally group consecutive thinking messages by turn.

Each turn now shows its own collapsible thinking tile placed inline
before its assistant response, with correct per-turn isActive state
and turnStartedAt from the matching run.

Also fixes a latent index-mismatch bug in getAssistantMessagePhase where
the visible-messages index was compared against the full session.messages
index, potentially hiding the Final badge when thinking messages existed.
Switched to ID-based comparison.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 16:53:28 +01:00
David KayaandCopilot fb3e80ec47 refactor: move git panel to tabbed bottom panel alongside terminal
Replace the narrow 256px sidebar git section with a shared tabbed
bottom panel that hosts both Terminal and Git as peer tabs. This gives
git operations full horizontal width for file paths, diff previews,
branch lists, and commit history.

- Create BottomPanel with shared resize handle, tab bar, and content
  switching (terminal stays mounted via CSS visibility when inactive)
- Simplify TerminalPanel by extracting resize/close to BottomPanel
- Add InlineGitPill toggle in composer footer next to Terminal pill
- Wire tab switching: clicking active tab closes panel, clicking
  inactive tab switches to it
- Remove GitPanel from ActivityPanel sidebar
- Update ARCHITECTURE.md to describe tabbed bottom panel layout

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 16:51:51 +01:00
David KayaandCopilot 5a71539705 feat: add git workflow frontend components
- RunChangeSummaryCard: post-run change review card with expandable
  file diffs, origin badges, selective discard with confirmation,
  and commit composer launch button
- CommitComposer: slide-over panel for staging files, editing AI-
  suggested commit messages, conventional commit type selection,
  and commit with optional push
- GitPanel: embedded activity panel section with branch management,
  push/pull/fetch operations, working tree inspection with inline
  diffs, and recent commit history
- Wire RunChangeSummaryCard into RunTimeline after completed runs
- Wire CommitComposer as overlay in App.tsx with state management
- Wire GitPanel into ActivityPanel for non-scratchpad sessions
- Update ARCHITECTURE.md with frontend git integration description

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 16:43:45 +01:00
David KayaandCopilot 906433f408 feat: add git workflow backend operations
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 16:43:13 +01:00
David KayaandCopilot 44d0ab07db feat: add git integration enhancements (phase 1)
Real-time git awareness:
- Auto-refresh git context on window focus and after run completion
- Periodic background polling (60s interval, configurable via settings)
- Pre-run working tree snapshot capture for project-backed runs
- Debounced refresh scheduling to coalesce rapid triggers

Backend (main process):
- GitService.captureWorkingTreeSnapshot() with per-file metadata
- Enriched parseWorkingTree() producing ProjectGitWorkingTreeFile entries
- AryxAppService: scheduleProjectGitRefresh(), periodic timer, focus hook
- preRunGitSnapshot persisted on SessionRunRecord with normalization

Frontend (renderer):
- Settings toggle for auto-refresh (General > Git section)
- RunTimeline: git baseline indicator showing branch and change summary
- Enhanced GitContextBadge tooltip with change breakdown details
- ChatPane: enriched git tooltip with staged/modified/untracked counts
- New IPC channel: setGitAutoRefreshEnabled

Tests: 8 new tests covering snapshot capture, refresh scheduling,
scratchpad skip behavior, and auto-refresh setting persistence.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 16:37:02 +01:00
David KayaandCopilot 49933f218b feat(website): add light mode with theme toggle
- Add [data-theme='light'] CSS variable overrides for warm parchment surface palette
- Add sun/moon theme toggle button in nav (desktop + mobile)
- Inline FOUC-prevention script in <head> reading localStorage/prefers-color-scheme
- Screenshot <img> elements swap between dark/light variants via data attributes
- Dynamic meta theme-color update and proper ARIA labels on toggle
- Adjust grain overlay, dot-grid, and gradient-text for light mode

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 16:35:17 +01:00
David Kaya 63acd9e87e Merge branch 'main' of https://github.com/davidkaya/aryx 2026-03-31 14:20:19 +02:00
David KayaandCopilot ebb506a359 feat: show platform-specific Copilot CLI install instructions when CLI is missing
Replace the generic 'Install the copilot CLI' one-liner with a rich,
platform-tabbed installation guide. Auto-detects macOS/Windows/Linux and
shows the recommended install command (Homebrew, WinGet, or install script)
plus alternatives (npm). Includes copy-to-clipboard, auth step, and a
refresh button.

- Extend platform.ts with isWindows, isLinux, detectedPlatform
- Add cliInstallInstructions.ts with per-platform install data
- Add CliInstallGuide component in settings/ with platform tabs
- Integrate into CopilotStatusCard for copilot-cli-missing state
- Enhance WelcomePane with CliMissingCard showing quick-start command

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 12:34:01 +02:00
Dávid KayaandGitHub b25da56f5e chore: Add Contributor Covenant Code of Conduct
This document outlines the standards of behavior for community members, including pledges, acceptable and unacceptable behaviors, enforcement responsibilities, and consequences for violations.
2026-03-31 11:41:20 +02:00
Dávid KayaandGitHub 30ff81009a chore: Change Dependabot update interval from weekly to daily 2026-03-31 11:31:59 +02:00
David Kaya 9a261780c6 chore: bump version to 0.0.19 2026-03-31 11:09:12 +02:00
David KayaandCopilot 9b7e4dd6e9 feat: format and syntax-highlight JSON arguments in approval popup
- Add deepParseJsonStrings utility to recursively unwrap JSON-encoded
  string values inside tool-call args before display
- Apply to McpDetail, CustomToolDetail, and HookDetail renderers
- Add lightweight regex-based JSON syntax highlighting (keys, strings,
  numbers, booleans, punctuation) in CollapsibleCode when content is JSON
- Remove unused Terminal import

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 10:43:28 +02:00
David KayaandCopilot 4bc2c327f7 fix: honor MCP server auto-approvals in hook flow
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-31 10:11:15 +02:00
David Kaya 36126f1c74 chore: bump version to 0.0.18 2026-03-30 17:20:40 +02:00
David KayaandCopilot bec50da2b4 fix: add DPI awareness to NSIS installer
Add ManifestDPIAware true via a custom NSIS include file so the
installer renders at native resolution on high-DPI displays.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 17:19:58 +02:00
David KayaandCopilot 535adc64be fix: enable background update checks in dev builds
Remove the isPackaged guard from AutoUpdateService.start() so that
background update checks run in both packaged and dev builds.
forceDevUpdateConfig already handles dev-mode config correctly via
dev-app-update.yml — the start() gate was preventing the 10s initial
check and 4h periodic checks from ever being scheduled in dev.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 17:18:02 +02:00
David KayaandCopilot 772c84fed3 feat: add in-app update notification banner to sidebar
Subscribe to auto-update status at the App level and render a compact
UpdateBanner in the sidebar footer when an update is available,
downloading, or downloaded:

- Available/downloading: subtle banner with progress bar, dismissable
- Downloaded: prominent 'Restart to update' action with glow effect

Clicking the banner opens Settings directly on the Troubleshooting
section via a new initialSection prop on SettingsPanel.

New files:
- src/renderer/components/ui/UpdateBanner.tsx

Modified files:
- App.tsx: subscribe to onUpdateStatus, wire props
- Sidebar.tsx: accept and render UpdateBanner
- SettingsPanel.tsx: add initialSection prop + export SettingsSection type
- styles.css: add update-banner-enter slide-up animation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 17:15:38 +02:00
David KayaandCopilot 9647b5fdb5 fix: restructure sidebar project header to prevent name truncation
Restructure the ProjectGroup header from a single cramped line into a
two-row layout so the project name gets adequate space:

- Row 1: chevron + icon + project name (min-w-0 flex-1) + hover actions
- Row 2: git branch badge + running/discovery/session count badges

Additional polish:
- Branch label uses font-mono (JetBrains Mono) for developer feel
- Branch max-width increased from 80px to 140px for better readability
- Project name tooltip shows full name and path on hover
- Scratchpad keeps session count inline on the identity row

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 17:02:47 +02:00
David Kaya 5946dc75ba chore: bump package.json to 0.0.17 2026-03-30 16:33:17 +02:00
David KayaandCopilot 59d3c81f9f fix: classify unstreamed sub-agent messages as thinking
Sub-agent messages bypass the streaming path (turn-delta) entirely due to
SDK batching behavior. They arrive only at turn-complete time via
FinalizeCompletedMessages. Without classification, they appear as separate
chat bubbles cluttering the transcript.

Two-pronged fix:

Sidecar: FinalizeCompletedMessages now tags messages from
_reclassifiedMessageIds with MessageKind='thinking'. This covers messages
that WERE streamed and reclassified during the turn. Added MessageKind
property to ChatMessageDto.

Main process: finalizeTurn detects unstreamed assistant messages (not in
existing session.messages) and classifies them as thinking when a visible
response was already streamed. Emits message-reclassified events so the
renderer can update incrementally, though the primary path is the
workspace:updated broadcast which already includes the correct messageKind.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 16:24:30 +01:00
David KayaandCopilot deb5c96d58 feat: render thinking process UI for intermediate agent messages
Wire message-reclassified sidecar events through the main process to
the renderer, where reclassified messages are filtered out of the main
transcript and collected into a collapsible ThinkingProcess component.

Main process changes:
- Route message-reclassified via dedicated onMessageReclassified callback
- Add applyMessageReclassified handler that sets messageKind and emits
  the session event for the renderer
- Forward assistant-intent and reasoning-delta as turn-scoped events

Renderer changes:
- Split session.messages into visibleMessages and thinkingMessages
- Render ThinkingProcess above the last assistant message
- ThinkingProcess auto-expands during active turns, collapses on finish
- Update messagePhase to skip thinking-kind messages for final detection

Tests:
- 3 new sessionWorkspace tests for reclassification apply/dedup/ignore
- 2 new messagePhase tests for thinking-kind handling
- Updated runTurnPending test fixtures for new callback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 16:16:37 +01:00
David KayaandCopilot 56de8b7bd6 feat: add thinking protocol events
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 16:11:58 +01:00
David KayaandCopilot 38dd358755 docs: document bookmarks panel in README, ARCHITECTURE, and website
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 16:11:17 +01:00
David KayaandCopilot 770a0f3529 feat: add bookmarks panel for viewing pinned messages across sessions
Add a new BookmarksPanel accessible via Ctrl/Cmd+Shift+B or the
command palette (View Bookmarks). The panel lists all pinned messages
across all sessions globally, with:

- Click-to-navigate: switches session and scrolls to the message
- Inline unpin: remove bookmarks directly from the panel
- Keyboard navigation: arrow keys, Enter, Escape
- Empty state when no messages are pinned

New shared helper listPinnedMessages() in sessionLibrary.ts derives
pinned messages from the workspace state in the renderer.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 16:05:08 +01:00
David KayaandCopilot e37d69bd63 fix: add macOS traffic light inset to sidebar header
Position macOS traffic light buttons (trafficLightPosition) in the
BrowserWindow and add conditional left padding to the sidebar header
so the app logo and title clear the window management controls.

- Create shared platform detection utility (src/renderer/lib/platform.ts)
- Set trafficLightPosition { x: 16, y: 22 } on macOS in createMainWindow
- Apply pl-20 (80px) left padding to the sidebar header on macOS
- Consolidate navigator.platform check from keyboardShortcuts.ts

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 16:04:45 +01:00
David KayaandCopilot c01a427d8a feat(website): add OG hero preview image for link sharing
Replace the raw logo OG image with a designed 1200×630 hero card
generated at build time using satori + @resvg/resvg-js.

- Add generate-og.ts script with branded dark card design (gradient
  orbs, geometric rings, logo, title, tagline)
- Wire generation into build pipeline (runs before astro build)
- Update meta tags with absolute URLs, dimensions, and twitter:image
- Configure site URL (https://aryx.app) in astro.config.mjs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 16:01:09 +01:00
David KayaandCopilot 01b3949557 docs: add trademark disclaimer to README and website footer
GitHub and GitHub Copilot are trademarks of Microsoft Corporation.
Aryx is an independent project, not affiliated with or endorsed by
Microsoft or GitHub.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 10:38:25 +02:00
David Kaya c01110979c fix: link in readme 2026-03-30 10:27:21 +02:00
David Kaya a034b333b3 chore: bump package.json to 0.0.16 2026-03-30 10:26:30 +02:00
David KayaandCopilot 41289c960b fix: enable update checks in dev mode via forceDevUpdateConfig
Add dev-app-update.yml so electron-updater can check GitHub releases
even when running with bun run dev. Remove the isPackaged guard from
checkForUpdates() so manual clicks always contact the update server.
Automatic periodic checks remain disabled in dev mode.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 10:24:07 +02:00
David KayaandCopilot 33b293271e fix: add up-to-date state so Check for updates gives visible feedback
When electron-updater reports no update available, the service now
transitions to 'up-to-date' instead of reverting silently to 'idle'.
The troubleshooting UI shows a green check icon, 'Up to date' label,
and 'You are running the latest version of Aryx.' description.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 10:20:05 +02:00
David KayaandCopilot cbcf239a0a fix: MCP group toggle styling and server-level approval UX
Two fixes in the auto-approval pill:

1. GroupToggle now uses brand-gradient-bg with glow shadow and matches
   ToggleSwitch sm dimensions, making MCP group toggles visually
   consistent with individual tool toggles.

2. Individual tool toggles now reflect server-level approval state.
   When a server key is approved, all tool rows show as enabled.
   Toggling a single tool OFF in a server-approved group expands the
   server key to individual tool IDs minus the excluded tool, enabling
   the 'approve all → disable one specific tool' workflow.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 10:14:13 +02:00
David KayaandCopilot c702cf88e2 fix: approval pill count mismatch with duplicate MCP tool names
When multiple MCP servers expose tools with the same name, the approval
pill showed an incorrect count (e.g. 150/300) even when everything was
approved.  The numerator used a Set<string> to deduplicate by tool ID,
so shared tools were counted once, while the denominator counted each
group occurrence independently.

Extract countApprovedToolsInGroups() into shared/domain/tooling.ts and
switch to per-group counting that mirrors the totalItemCount formula.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 10:07:41 +02:00
David KayaandCopilot 8a4d23c22a feat: add Enable all / Approve all buttons to inline pills
Add bulk action buttons to the Tools and Auto-approval pill popovers:

- Tools pill: sticky header with Enable all / Disable all toggle button
  that enables or disables every MCP server and LSP profile at once
- Approval pill: Approve all / Unapprove all button in the existing
  sticky header next to the Reset button, approving or clearing all
  tool and server approval keys

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 09:49:20 +02:00
David KayaandCopilot 042cec6065 fix: resolve hook permissions to proper categories for approval
When the pre-tool-use hook returns 'ask', the Copilot CLI creates
PermissionRequestHook instead of categorized PermissionRequestRead/
Write/Shell. This caused 'Permission: hook' labels and broke category-
based auto-approval ('Always approve read' wouldn't cover grep/glob).

Add ResolveHookToolCategory mapping in CopilotApprovalCoordinator to
map known tool names (view/grep/glob→read, edit/create→write, etc.)
to their permission categories. Wire into GetFallbackToolName,
BuildPermissionApprovalEvent, and CreateApprovalPolicyOutput so:
- Approval banner shows 'Permission: read' instead of 'Permission: hook'
- 'Always approve' stores the category key, covering all tools in it
- Hook short-circuits when category is already auto-approved
Unknown tools (MCP, custom) keep existing 'hook' behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 09:41:51 +02:00
David KayaandCopilot 39fee48c0b feat: polish troubleshooting page and fix timeline icons
- Add 'Built with ❤ by Dávid Kaya' attribution footer to the
  troubleshooting settings page, matching the website footer
- Add 'Check for updates' action to troubleshooting, wired to the
  existing auto-updater IPC with live status feedback (checking,
  available, downloading, downloaded, error states)
- Fix timeline event icons: increase node circle from 15px to 18px,
  shrink icons from 14px to 10px for proper padding, and force white
  icon color on running-state gradient background to eliminate the
  purple-circle overlay clash

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 09:04:26 +02:00
David Kaya 27e784ab9b chore: bump version to 0.0.13 2026-03-30 01:10:52 +02:00
David Kaya 11a10ea53c fix: icon missing on windows 2026-03-30 01:10:33 +02:00
David KayaandCopilot 3318a14d32 docs: rewrite README and redesign website feature showcase
README:
- Full rewrite with confident, direct tone
- New structure: pitch → highlights → how-it-works → categorized feature tables
- Add 9 completed features (notifications, command palette, keyboard shortcuts,
  session search, onboarding, system tray, branching, message actions, animations)
- Update prerequisites to cross-platform (Windows, macOS, Linux)
- Drop redundant sections

Website:
- Replace flat 4x3 grid of 12 identical feature cards with 3 named categories
  (Workspace & Sessions, Agent Intelligence, Developer Tooling)
- Compact icon+title+description layout instead of heavy bordered cards
- Add 9 new completed features (22 features total, neatly organized)
- Each category has a colored accent heading with rule lines
- Hover reveals with subtle background transition

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 01:04:26 +02:00
David KayaandCopilot 3b69a9c0f7 chore: bump version to 0.0.12
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 00:39:25 +02:00
David KayaandCopilot 023ea9b3e4 fix: set releaseType to release in electron-builder publish config
The release workflow creates a published GitHub release before
electron-builder runs. electron-builder defaults to releaseType=draft,
causing a type mismatch that prevents all asset uploads.

Setting releaseType to release tells electron-builder to upload assets
to the existing published release.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 00:38:34 +02:00
David KayaandCopilot bf2a454ef2 fix: auto-allow internal orchestration tools
Allow SDK-managed orchestration tools to bypass pre-tool approval prompts so Aryx matches Copilot CLI behavior for non-side-effectful meta tools.

Keep store_memory under the existing memory approval category.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-30 00:36:52 +02:00
Dávid KayaandGitHub a1932788ae feat: GPLv3 license 2026-03-30 00:26:14 +02:00
252 changed files with 43318 additions and 11369 deletions
+11
View File
@@ -0,0 +1,11 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "daily"
+2 -2
View File
@@ -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'
+68 -30
View File
@@ -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
@@ -55,8 +55,8 @@ 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, 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 |
| 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, streaming deltas and activity, streamed text assembly | UI, workspace persistence, Electron APIs | Line-delimited JSON over stdio |
| External systems | Git data, Copilot account/model access, OS window chrome | Application state and UI behavior | Controlled adapters owned by main or sidecar |
This split is the most important architectural feature in the app. It is what keeps the system understandable as more capabilities are added.
@@ -89,7 +89,7 @@ sequenceDiagram
R->>P: Invoke typed API
P->>M: IPC request
M->>M: Append user message
M->>M: Create run record and mark session running
M->>M: Capture pre-run git snapshot, create run record, and mark session running
M->>S: run-turn command
S->>C: Execute workflow
C-->>S: Partial output / tool activity / handoffs / input requests
@@ -97,7 +97,7 @@ sequenceDiagram
M-->>R: Push session events and workspace updates
C-->>S: Final messages or turn boundary
S-->>M: Completion or error
M->>M: Finalize run and persist state
M->>M: Finalize run, compute post-run git summary, refresh project git state, and persist state
M-->>R: Final workspace snapshot
```
@@ -108,7 +108,8 @@ This flow is important because it shows that Aryx is not architected as a simple
The durable state of the app is a **workspace**. The workspace contains:
- connected projects
- orchestration patterns
- workflows
- workflow templates
- sessions
- settings
- run history
@@ -124,36 +125,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.
### Patterns
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 describe how agents collaborate. The architecture supports:
### Workflows
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
@@ -161,6 +168,8 @@ A session is the working unit of the product. It binds together:
This is how Aryx keeps "ongoing work" first class. Sessions can survive restarts, can be organized, and can accumulate operational history over time.
Individual messages can be pinned as bookmarks. A dedicated bookmarks panel (`BookmarksPanel`) lists all pinned messages across all sessions globally, navigating to the originating session and message on selection. This data is derived renderer-side from the workspace state; there is no separate backend API.
### Runs
Each user turn becomes a **run**. A run is more than the final assistant output; it also tracks:
@@ -170,8 +179,9 @@ Each user turn becomes a **run**. A run is more than the final assistant output;
- which activity happened during the turn
- partial streaming 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
@@ -185,6 +195,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
@@ -201,22 +213,28 @@ 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 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
- **Session usage events**: current token count and context-window limit from `session.usage_info` for context-bar rendering
- **Pending-messages-modified events**: emitted when mid-turn steering changes the pending message queue
- **Workflow diagnostic events**: normalized warnings and errors from Agent Framework (`WorkflowWarningEvent`, `WorkflowErrorEvent`, `ExecutorFailedEvent`) with optional executor or subworkflow metadata for richer debugging surfaces
- **Workflow checkpoint events**: emitted at Agent Framework superstep boundaries with workflow session ID, checkpoint ID, step number, and checkpoint-store path so the main process can prepare crash-recovery state
These events flow through a single `onTurnScopedEvent` callback on the `runTurn` command, avoiding per-event-type callback proliferation. The main process maps each event to a `SessionEventRecord` and pushes it to the renderer, where lightweight state maps (activity, usage, turn-event log) consume them without touching the persisted workspace.
@@ -226,7 +244,9 @@ 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.
## Security model
@@ -286,7 +306,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.
@@ -295,14 +315,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 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.
@@ -330,7 +352,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
@@ -338,13 +361,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
+128
View File
@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
noble_pinhole.0g@icloud.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+71 -112
View File
@@ -8,138 +8,97 @@
A desktop workspace for Copilot-powered work across real projects.
</p>
Aryx is built for people who want more than a generic AI chat window. It gives you a place to ask quick questions, connect real projects, run reusable agent patterns, and keep ongoing work organized in one app.
<p align="center">
<a href="https://github.com/davidkaya/aryx/releases">Download</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="https://aryx.app">Website</a>&nbsp;&nbsp;·&nbsp;&nbsp;<a href="https://github.com/davidkaya/aryx/issues">Issues</a>
</p>
It works especially well when you want AI help that stays grounded in an actual codebase: your folders, your repository state, your current branch, and your active work.
---
## Why use Aryx?
Aryx is a desktop app that turns GitHub Copilot into a full workspace. Connect real projects, orchestrate multi-agent workflows, and keep persistent sessions organized — instead of starting from scratch in a blank chat window every time. It runs on Windows, macOS, and Linux.
- **Start fast** with a scratchpad conversation for quick questions and ad-hoc work.
- **Work against real projects** by attaching local folders and letting Aryx stay aware of repository context.
- **Go beyond one assistant** with orchestration patterns such as single-agent, sequential, concurrent, handoff, and group-chat flows.
- **See what is happening** with live activity for each agent while a run is in progress, including sub-agent delegations, hook lifecycle, skill invocations, and context compaction.
- **Stay organized** with persistent sessions you can rename, pin, archive, delete, and return to later.
- **Steer while agents work** by sending follow-up messages mid-turn — the agent receives your input immediately.
- **Attach images** to any message for visual context the model can reason about.
- **Tune how you work** by choosing models and reusing saved patterns that fit different tasks.
## Highlights
## What you can do in the app
- **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.
- **Extensible tooling** — MCP servers, LSP profiles, project hooks, and fine-grained tool approval controls.
- **Keyboard-first** — command palette, rich shortcuts, mid-turn steering, and a built-in terminal.
### Ask quick questions in a scratchpad
## How it works
If you just want to think through an idea, draft something, or ask for help without connecting a project, start a scratchpad session and begin chatting.
Each scratchpad session keeps its own isolated working directory, so files created in one scratchpad do not leak into another.
1. **Launch Aryx** — the app checks your Copilot CLI connection and shows status on the home screen.
2. **Connect a project** or open a scratchpad for quick questions without any setup.
3. **Pick a pattern** — choose a single-agent chat or a saved multi-agent orchestration workflow.
4. **Work** — ask questions, steer agents mid-turn, watch live activity, and keep the session for later.
### Connect a real project
## Features
Add a local folder when you want help that is grounded in your work. Aryx is designed to feel strongest when it is attached to a real project instead of acting like a general-purpose chatbot.
### Workspace & sessions
### Choose how agents collaborate
| Feature | Description |
|---------|-------------|
| Scratchpad sessions | Quick questions with isolated working directories — no project setup needed |
| Persistent sessions | Rename, pin, archive, duplicate, and return to sessions across restarts |
| Session branching | Fork a session at any user message to explore a different direction |
| Session search | Full-text search across all session messages, not just titles |
| 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 |
Aryx supports several ways of working:
### Agent intelligence
- **Single** for direct one-agent help
- **Sequential** for pipeline-style work where each agent sees the full conversation and appends its contribution
- **Concurrent** for parallel exploration where the final turn aggregates multiple independent responses
- **Handoff** for agent-to-agent delegation, with the next user turn continuing when a specialist needs more input
- **Group chat** for round-robin collaborative refinement across multiple agent turns
| 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 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 |
### Add global MCPs and LSPs
### Developer tooling
You can define MCP servers and LSP profiles once in **Settings**, then enable the ones you want for each project-backed session from the right-side **Activity** panel.
| Feature | Description |
|---------|-------------|
| Real project context | Attach folders and repos — see branch, dirty state, and ahead/behind status |
| MCP servers | Define servers globally, enable per session, auto-discover from project configs |
| LSP profiles | Language server integration for code intelligence in agent workflows |
| Tool approval | Fine-grained approval policies with pattern-level defaults and per-session overrides |
| Project hooks | Auto-discovers `.github/hooks/*.json` and runs lifecycle hooks in the sidecar |
| Image input | Attach screenshots, diagrams, or photos for visual reasoning |
| Integrated terminal | Full PTY-backed terminal inside the workspace (`Ctrl+\``) |
| Command palette | `Ctrl+K` fuzzy search across actions, sessions, and settings |
| Keyboard shortcuts | Comprehensive keybindings with a cheat sheet via `Ctrl+/` |
This keeps machine-wide tooling reusable while still letting each session decide which external tools the agent can use.
## Prerequisites
Patterns now require tool-call approval by default. They can also store default auto-approval for known MCP and LSP tools, and each session can override those auto-approval defaults from the Activity panel before a run starts.
Project-backed sessions also honor GitHub Copilot CLI-style hook files from `.github/hooks/*.json`. Aryx discovers them automatically in the connected repository and runs the supported lifecycle hooks inside the sidecar, with `preToolUse` deny decisions applied before Aryx's own approval policy.
### Watch runs as they happen
You can follow agent activity while a session is running, which makes longer or more complex workflows easier to trust and understand. The activity panel shows sub-agent delegations, skill invocations, hook lifecycle events, and context compaction in real time. A context-usage bar below the composer shows how much of the model's context window the current session occupies.
### Steer agents mid-turn
While an agent is working, you can type a follow-up message that is delivered immediately into the current turn. This lets you redirect, refine, or add context without waiting for the turn to finish. The composer shows an amber "steering" indicator when a message will be injected into an active run.
### Attach images
You can attach images (JPEG, PNG, GIF, WebP) to any message using the clip button, drag-and-drop, or paste from clipboard. Image attachments are sent as base64-encoded blobs so the model can reason about visual content alongside your text.
### Keep important work around
Sessions are persistent, so you can return to ongoing work instead of starting from scratch every time. You can also rename, pin, archive, delete, and duplicate sessions as your workspace grows.
## Before you start
To use Aryx comfortably, make sure you have:
- a **Windows machine**
- **GitHub Copilot CLI** installed and available as `copilot`
- an active **GitHub Copilot sign-in**
- a local folder or git repository ready to connect if you want project-aware help
- any MCP servers or language servers you want to use installed and reachable from your machine
- An active **GitHub Copilot** sign-in
- Windows, macOS, or Linux
Aryx includes connection status in the app so you can quickly tell whether Copilot is ready before you start a session.
Aryx shows your Copilot connection status in the app so you know if authentication is ready before starting a session.
## Getting started
## Development
1. **Open Aryx**
Launch the app and head to settings if you want to confirm your Copilot connection first.
```sh
bun run test # typecheck + unit tests
bun run sidecar:test # backend tests
bun run build # full build (electron + sidecar)
2. **Check that Copilot is ready**
Make sure the app shows that Copilot is installed and authenticated.
bun run package # package for current platform → release/
bun run installer # create installable artifact
bun run publish-release # publish to GitHub Releases
```
3. **Choose how you want to begin**
Start a scratchpad session for quick work, or add a project if you want the conversation grounded in a local codebase.
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.
4. **Pick a pattern**
Use a simple single-agent setup to begin, or choose a saved multi-agent pattern when you want a more structured workflow.
## Trademarks
5. **Configure optional tooling**
If you want MCP or LSP support, add the global definitions in settings and then enable the ones you want for the current session from the Activity panel. Aryx also surfaces Copilot CLI runtime tools for approval management: tool calls require approval by default, and you can set pattern-level auto-approval defaults and override them per session.
6. **Start working**
Ask a question, describe a task, or explore a project. As the run progresses, you can watch the participating agents and keep the session for later.
## When Aryx feels most useful
Aryx shines when you want to:
- move from quick chat to deeper multi-step work without leaving the app
- keep AI conversations tied to actual projects instead of isolated prompts
- compare different ways of approaching the same task
- reuse patterns for recurring workflows
- maintain a history of meaningful sessions instead of disposable chats
## Build and release automation
For local validation, run:
- `bun run test`
- `bun run sidecar:test`
- `bun run build`
To package the current platform into `release/`, run:
- `bun run package`
To create the installable artifacts for the current platform, run:
- `bun run installer`
To publish packaged artifacts and update metadata to GitHub Releases, run:
- `bun run publish-release`
GitHub Actions runs validation on pushes and pull requests, and tagged releases now use `electron-builder` to publish Windows (NSIS), macOS (DMG + ZIP for updater metadata), and Linux (AppImage) artifacts directly to GitHub Releases. Packaged builds use `electron-updater` against those releases for in-app updates.
Tagged macOS release jobs now prepare signing assets from the GitHub secrets `APPLE_CERT_P12_BASE64`, `APPLE_CERT_PASSWORD`, `APPLE_API_KEY_P8`, `APPLE_API_KEY_ID`, `APPLE_API_ISSUER`, and `APPLE_TEAM_ID`, normalize the decoded PKCS#12 into a macOS-compatible container, preflight `security import` against a temporary keychain, and then export the standard `electron-builder` environment variables (`CSC_LINK`, `CSC_KEY_PASSWORD`, `APPLE_API_KEY`, `APPLE_API_KEY_ID`, `APPLE_API_ISSUER`, `APPLE_TEAM_ID`) before packaging. That same release path signs and notarizes the macOS artifacts as part of publication.
Windows builds are currently packaged without Authenticode signing, so Aryx disables `electron-updater`'s Windows signature verification until a signing certificate is configured. macOS auto-update metadata still requires a ZIP artifact alongside the DMG build.
## Current focus
Aryx is focused on local, project-based work with your GitHub Copilot account. It already covers the essentials for working with projects, sessions, and reusable orchestration patterns, and it is growing toward a fuller AI workstation experience over time.
If you want an AI app that feels closer to a control room for real work than a blank chat box, Aryx is built for that.
GitHub and GitHub Copilot are trademarks of Microsoft Corporation. Aryx is an independent project, not affiliated with or endorsed by Microsoft or GitHub.
+1
View File
@@ -0,0 +1 @@
ManifestDPIAware true
+4
View File
@@ -0,0 +1,4 @@
provider: github
owner: davidkaya
repo: aryx
releaseType: release
+12
View File
@@ -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: {
+6 -4
View File
@@ -1,7 +1,7 @@
{
"name": "aryx",
"version": "0.0.8",
"description": "Electron orchestrator for Copilot-powered agent workflows across multiple projects.",
"version": "0.0.25",
"description": "Orchestrator for Copilot-powered agent workflows across multiple projects.",
"private": true,
"main": "dist-electron/main/index.js",
"scripts": {
@@ -110,7 +110,8 @@
"publish": {
"provider": "github",
"owner": "davidkaya",
"repo": "aryx"
"repo": "aryx",
"releaseType": "release"
},
"win": {
"target": [
@@ -118,13 +119,14 @@
],
"icon": "assets/icons/windows/icon.ico",
"artifactName": "Aryx-windows-${arch}.${ext}",
"signAndEditExecutable": false,
"signAndEditExecutable": true,
"verifyUpdateCodeSignature": false
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
"include": "assets/installer.nsh",
"installerIcon": "assets/icons/windows/icon.ico",
"uninstallerIcon": "assets/icons/windows/icon.ico"
},
@@ -2,17 +2,17 @@
<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.0.0" />
<PackageReference Include="Microsoft.Agents.AI.GitHub.Copilot" Version="1.0.0-preview.260402.1" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.0.0" />
</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;
}
@@ -85,6 +174,7 @@ public sealed class ChatMessageDto
public string AuthorName { get; init; } = string.Empty;
public string Content { get; init; } = string.Empty;
public string CreatedAt { get; init; } = string.Empty;
public string? MessageKind { get; set; }
public IReadOnlyList<ChatMessageAttachmentDto> Attachments { get; init; } = [];
}
@@ -97,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
@@ -171,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
@@ -184,9 +277,24 @@ 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
@@ -310,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
@@ -331,16 +439,26 @@ public sealed class TurnCompleteEventDto : SidecarEventDto
public bool Cancelled { get; init; }
}
public sealed class MessageReclassifiedEventDto : SidecarEventDto
{
public string SessionId { get; init; } = string.Empty;
public string MessageId { get; init; } = string.Empty;
public string NewKind { get; init; } = string.Empty;
}
public sealed class AgentActivityEventDto : SidecarEventDto
{
public string SessionId { get; init; } = string.Empty;
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; }
}
@@ -376,6 +494,23 @@ public sealed class SkillInvokedEventDto : SidecarEventDto
public string? Description { get; init; }
}
public sealed class AssistantIntentEventDto : SidecarEventDto
{
public string SessionId { get; init; } = string.Empty;
public string? AgentId { get; init; }
public string? AgentName { get; init; }
public string Intent { get; init; } = string.Empty;
}
public sealed class ReasoningDeltaEventDto : SidecarEventDto
{
public string SessionId { get; init; } = string.Empty;
public string? AgentId { get; init; }
public string? AgentName { get; init; }
public string ReasoningId { get; init; } = string.Empty;
public string ContentDelta { get; init; } = string.Empty;
}
public sealed class HookLifecycleEventDto : SidecarEventDto
{
public string SessionId { get; init; } = string.Empty;
@@ -463,6 +598,28 @@ public sealed class PendingMessagesModifiedEventDto : SidecarEventDto
public string? AgentName { get; init; }
}
public sealed class WorkflowCheckpointSavedEventDto : SidecarEventDto
{
public string SessionId { get; init; } = string.Empty;
public string WorkflowSessionId { get; init; } = string.Empty;
public string CheckpointId { get; init; } = string.Empty;
public string StorePath { get; init; } = string.Empty;
public int StepNumber { get; init; }
}
public sealed class WorkflowDiagnosticEventDto : SidecarEventDto
{
public string SessionId { get; init; } = string.Empty;
public string Severity { get; init; } = string.Empty;
public string DiagnosticKind { get; init; } = string.Empty;
public string Message { get; init; } = string.Empty;
public string? AgentId { get; init; }
public string? AgentName { get; init; }
public string? ExecutorId { get; init; }
public string? SubworkflowId { get; init; }
public string? ExceptionType { get; init; }
}
public sealed class SessionsListedEventDto : SidecarEventDto
{
public IReadOnlyList<CopilotSessionInfoDto> Sessions { get; init; } = [];
@@ -566,6 +723,13 @@ public sealed class ExitPlanModeRequestedEventDto : SidecarEventDto
public string? RecommendedAction { get; init; }
}
public sealed class WorkflowCheckpointResumeDto
{
public string WorkflowSessionId { get; init; } = string.Empty;
public string CheckpointId { get; init; } = string.Empty;
public string StorePath { get; init; } = string.Empty;
}
public sealed class CommandErrorEventDto : SidecarEventDto
{
public string Message { get; init; } = string.Empty;
@@ -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; }
}
@@ -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,30 +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);
}
if (!string.Equals(pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase))
{
return JoinInstructionBlocks(baseInstructions, repositoryInstructions, workspaceGuidance, planModeGuidance);
}
string runtimeGuidance = agentIndex == 0
? """
You are the routing gate for this handoff workflow.
Your job is to classify the request and hand it off to the most appropriate specialist as soon as you know who should own the substantive work.
For any substantive task, your next meaningful action must be the actual handoff rather than a plain-text promise to delegate later.
Do not inspect files, call tools, draft the implementation, or produce the final user-facing answer yourself once a specialist is appropriate.
Do not claim that you handed work off unless you actually executed the handoff.
Only answer directly if the user is asking for pure triage or a minimal clarification that must happen before delegation.
"""
: """
You are a specialist participating in a handoff workflow.
Once the triage agent hands work to you, you own the substantive answer within your specialty and should carry it through.
Do not push the actual work back to triage unless you are blocked or the request is clearly outside your specialty.
""";
return JoinInstructionBlocks(baseInstructions, repositoryInstructions, workspaceGuidance, planModeGuidance, runtimeGuidance);
return JoinInstructionBlocks(
baseInstructions,
repositoryInstructions,
promptInvocationInstructions,
workspaceGuidance,
planModeGuidance);
}
private static string JoinInstructionBlocks(params string[] blocks)
@@ -80,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);
}
}
@@ -0,0 +1,786 @@
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 class AgentWorkflowTurnRunner : ITurnWorkflowRunner
{
private const string HandoffFunctionPrefix = "handoff_to_";
private readonly WorkflowValidator _workflowValidator;
private readonly WorkflowRunner _workflowRunner = new();
private readonly IProviderTurnSupport _providerTurnSupport;
internal AgentWorkflowTurnRunner(
IProviderTurnSupport providerTurnSupport,
WorkflowValidator? workflowValidator = null)
{
_providerTurnSupport = providerTurnSupport ?? throw new ArgumentNullException(nameof(providerTurnSupport));
_workflowValidator = workflowValidator ?? new WorkflowValidator();
}
public async Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
RunTurnCommandDto command,
Func<TurnDeltaEventDto, Task> onDelta,
Func<SidecarEventDto, Task> onEvent,
Func<ApprovalRequestedEventDto, Task> onApproval,
Func<UserInputRequestedEventDto, Task> onUserInput,
Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired,
Func<ExitPlanModeRequestedEventDto, Task> onExitPlanMode,
CancellationToken cancellationToken)
{
string? validationError = _workflowValidator.Validate(command.Workflow, command.WorkflowLibrary)
.FirstOrDefault()?.Message;
if (validationError is not null)
{
throw new InvalidOperationException(validationError);
}
TurnExecutionState state = new(command);
using CancellationTokenSource runCancellation =
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
IProviderTranscriptProjector? transcriptProjector = null;
try
{
await using ProviderAgentBundle bundle = await _providerTurnSupport.CreateAgentBundleAsync(
command,
state,
onEvent,
onApproval,
onUserInput,
runCancellation,
runCancellation.Token)
.ConfigureAwait(false);
transcriptProjector = bundle.TranscriptProjector;
ConfigureHookLifecycleEventSuppression(state, bundle);
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
? CheckpointManager.CreateJson(checkpointStore)
: null;
await using StreamingRun run = await OpenWorkflowRunAsync(
command,
workflow,
inputMessages,
checkpointManager).ConfigureAwait(false);
await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
await foreach (WorkflowEvent evt in run.WatchStreamAsync(runCancellation.Token).ConfigureAwait(false))
{
if (evt is RequestInfoEvent requestInfo
&& await TryHandleRequestPortRequestAsync(
command,
requestInfo,
run,
onUserInput,
runCancellation.Token).ConfigureAwait(false))
{
continue;
}
bool shouldEndTurn = await HandleWorkflowEventAsync(
command,
evt,
inputMessages,
state,
transcriptProjector,
onDelta,
onEvent)
.ConfigureAwait(false);
await EmitPendingEventsAsync(state, onDelta, onEvent).ConfigureAwait(false);
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
if (shouldEndTurn)
{
break;
}
}
await EmitPendingEventsAsync(state, onDelta, onEvent).ConfigureAwait(false);
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
return state.FinalizeCompletedMessages(transcriptProjector);
}
catch (OperationCanceledException) when (runCancellation.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
await EmitPendingEventsAsync(state, onDelta, onEvent).ConfigureAwait(false);
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
ExitPlanModeRequestedEventDto? exitPlanModeEvent =
_providerTurnSupport.ConsumePendingExitPlanModeRequest(command.RequestId);
if (exitPlanModeEvent is null || !state.HasPendingExitPlanModeRequest)
{
throw;
}
await onExitPlanMode(exitPlanModeEvent).ConfigureAwait(false);
return state.FinalizeCompletedMessages(
transcriptProjector ?? throw new InvalidOperationException("Provider transcript projector was not initialized."));
}
finally
{
_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))
{
return null;
}
DirectoryInfo checkpointDirectory = new(GetCheckpointStorePath(command));
return new FileSystemJsonCheckpointStore(checkpointDirectory);
}
internal static bool ShouldEnableWorkflowCheckpointing(RunTurnCommandDto command)
{
ArgumentNullException.ThrowIfNull(command);
return command.Workflow.Settings.Checkpointing.Enabled;
}
internal static string GetCheckpointStorePath(RunTurnCommandDto command)
{
ArgumentNullException.ThrowIfNull(command);
if (!string.IsNullOrWhiteSpace(command.ResumeFromCheckpoint?.StorePath))
{
return command.ResumeFromCheckpoint.StorePath;
}
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
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 environment.ResumeStreamingAsync(
workflow,
new CheckpointInfo(resumeFromCheckpoint.WorkflowSessionId, resumeFromCheckpoint.CheckpointId));
}
return environment.RunStreamingAsync(workflow, inputMessages.ToList(), command.RequestId);
}
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(
TurnExecutionState state,
ProviderAgentBundle bundle)
{
ArgumentNullException.ThrowIfNull(state);
ArgumentNullException.ThrowIfNull(bundle);
state.SuppressHookLifecycleEvents = !bundle.HasConfiguredHooks;
}
private static async Task EmitPendingEventsAsync(
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(
TurnExecutionState state,
Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired)
{
foreach (McpOauthRequiredEventDto request in state.DrainPendingMcpOauthRequests())
{
await onMcpOAuthRequired(request).ConfigureAwait(false);
}
}
public Task ResolveApprovalAsync(
ResolveApprovalCommandDto command,
CancellationToken cancellationToken)
{
return _providerTurnSupport.ResolveApprovalAsync(command, cancellationToken);
}
public Task ResolveUserInputAsync(
ResolveUserInputCommandDto command,
CancellationToken cancellationToken)
{
return _providerTurnSupport.ResolveUserInputAsync(command, cancellationToken);
}
private async Task<bool> TryHandleRequestPortRequestAsync(
RunTurnCommandDto command,
RequestInfoEvent requestInfo,
StreamingRun run,
Func<UserInputRequestedEventDto, Task> onUserInput,
CancellationToken cancellationToken)
{
if (!TryResolveRequestPortMetadata(command.Workflow, requestInfo, out WorkflowRequestPortMetadata? metadata))
{
return false;
}
UserInputRequest userInputRequest = CreateRequestPortUserInputRequest(metadata!, requestInfo);
UserInputResponse response = await _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,
TurnExecutionState state,
IProviderTranscriptProjector transcriptProjector,
Func<TurnDeltaEventDto, Task> onDelta,
Func<SidecarEventDto, Task> onEvent)
{
if (evt is ExecutorInvokedEvent invoked)
{
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}.");
}
return false;
}
if (evt is RequestInfoEvent requestInfo)
{
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
command,
requestInfo,
state.ActiveAgent,
state.ToolCalls);
if (activity is null)
{
bool requiresBoundary = WorkflowRequestInfoInterpreter.RequiresUserInputTurnBoundary(command, requestInfo);
TraceHandoff(
command,
$"Request info produced no activity for data type '{requestInfo.Request.Data.TypeId}'. Requires boundary: {requiresBoundary}.");
return requiresBoundary;
}
await EmitActivityAsync(command, state, activity, onEvent).ConfigureAwait(false);
return false;
}
if (TryCreateWorkflowCheckpointSavedEvent(command, evt, out WorkflowCheckpointSavedEventDto? checkpointSaved))
{
await onEvent(checkpointSaved).ConfigureAwait(false);
return false;
}
if (TryCreateWorkflowDiagnosticEvent(command, evt, state, out WorkflowDiagnosticEventDto? diagnostic))
{
await onEvent(diagnostic).ConfigureAwait(false);
return false;
}
if (evt is AgentResponseUpdateEvent update)
{
await HandleAgentResponseUpdateAsync(command, update, state, onDelta, onEvent).ConfigureAwait(false);
return false;
}
if (evt is ExecutorCompletedEvent completed)
{
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}.");
}
return false;
}
if (evt is WorkflowOutputEvent outputEvent)
{
List<ChatMessage> allMessages = outputEvent.As<List<ChatMessage>>() ?? [];
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,
TurnExecutionState state,
Func<TurnDeltaEventDto, Task> onDelta,
Func<SidecarEventDto, Task> onEvent)
{
AgentIdentity? updateAgent = null;
string authorName = update.ExecutorId;
string[] handoffFunctionCalls = update.Update.Contents
.OfType<FunctionCallContent>()
.Select(content => content.Name)
.Where(IsHandoffFunctionName)
.Distinct(StringComparer.Ordinal)
.ToArray();
if (state.TryResolveObservedAgentForMessage(update.Update.MessageId, out AgentIdentity observedMessageAgent))
{
updateAgent = observedMessageAgent;
authorName = observedMessageAgent.AgentName;
}
else if (state.TryResolveObservedAgentIdentity(
update.ExecutorId,
state.ActiveAgent,
out AgentIdentity resolvedUpdateAgent))
{
updateAgent = resolvedUpdateAgent;
authorName = resolvedUpdateAgent.AgentName;
}
else if (state.ActiveAgent is AgentIdentity activeAgent)
{
updateAgent = activeAgent;
authorName = activeAgent.AgentName;
TraceHandoff(
command,
$"Agent response update fell back to active agent {activeAgent.AgentName} ({activeAgent.AgentId}) for executor '{update.ExecutorId}' and message '{update.Update.MessageId ?? "<none>"}'.");
}
if (updateAgent.HasValue)
{
if (handoffFunctionCalls.Length > 0)
{
TraceHandoff(
command,
$"Agent response update from {updateAgent.Value.AgentName} ({updateAgent.Value.AgentId}) requested handoff via {string.Join(", ", handoffFunctionCalls)}.");
}
await state.EmitThinkingIfNeeded(updateAgent.Value, onEvent).ConfigureAwait(false);
}
else if (!string.IsNullOrEmpty(update.Update.Text) || handoffFunctionCalls.Length > 0)
{
TraceHandoff(
command,
$"Agent response update could not resolve agent for executor '{update.ExecutorId}' and message '{update.Update.MessageId ?? "<none>"}'.");
}
if (string.IsNullOrEmpty(update.Update.Text))
{
return;
}
string messageId = state.CreateMessageId(update.Update.MessageId);
if (!state.TryAppendDelta(
messageId,
authorName,
update.Update.Text,
out TranscriptSegment currentSegment))
{
return;
}
await onDelta(new TurnDeltaEventDto
{
Type = "turn-delta",
RequestId = command.RequestId,
SessionId = command.SessionId,
MessageId = messageId,
AuthorName = currentSegment.AuthorName,
ContentDelta = update.Update.Text,
Content = currentSegment.Content,
}).ConfigureAwait(false);
}
internal static async Task EmitActivityAsync(
RunTurnCommandDto command,
TurnExecutionState state,
AgentActivityEventDto activity,
Func<SidecarEventDto, Task> onEvent)
{
state.ApplyEvent(activity);
TraceHandoff(
command,
$"Activity emitted: {activity.ActivityType} -> {activity.AgentName ?? activity.AgentId ?? "<unknown>"}.");
await onEvent(activity).ConfigureAwait(false);
if (string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal)
&& !string.IsNullOrWhiteSpace(activity.AgentId)
&& !string.IsNullOrWhiteSpace(activity.AgentName))
{
AgentIdentity promotedAgent = state.ResolveAgentIdentity(activity.AgentId, activity.AgentName);
TraceHandoff(
command,
$"Promoting handoff target to thinking: {promotedAgent.AgentName} ({promotedAgent.AgentId}).");
await state.EmitThinkingIfNeeded(
promotedAgent,
onEvent).ConfigureAwait(false);
}
}
internal static bool TryCreateWorkflowCheckpointSavedEvent(
RunTurnCommandDto command,
WorkflowEvent evt,
out WorkflowCheckpointSavedEventDto checkpointSaved)
{
checkpointSaved = default!;
if (!ShouldEnableWorkflowCheckpointing(command)
|| evt is not SuperStepCompletedEvent superStepCompleted
|| superStepCompleted.CompletionInfo?.Checkpoint is not CheckpointInfo checkpoint)
{
return false;
}
checkpointSaved = new WorkflowCheckpointSavedEventDto
{
Type = "workflow-checkpoint-saved",
RequestId = command.RequestId,
SessionId = command.SessionId,
WorkflowSessionId = checkpoint.SessionId,
CheckpointId = checkpoint.CheckpointId,
StorePath = GetCheckpointStorePath(command),
StepNumber = superStepCompleted.StepNumber,
};
return true;
}
private static bool TryCreateWorkflowDiagnosticEvent(
RunTurnCommandDto command,
WorkflowEvent evt,
TurnExecutionState state,
out WorkflowDiagnosticEventDto diagnostic)
{
diagnostic = default!;
switch (evt)
{
case ExecutorFailedEvent executorFailed:
{
AgentIdentity? agent = state.TryResolveObservedAgentIdentity(
executorFailed.ExecutorId,
state.ActiveAgent,
out AgentIdentity resolvedAgent)
? resolvedAgent
: null;
Exception? exception = executorFailed.Data;
diagnostic = new WorkflowDiagnosticEventDto
{
Type = "workflow-diagnostic",
RequestId = command.RequestId,
SessionId = command.SessionId,
Severity = "error",
DiagnosticKind = "executor-failed",
Message = ResolveDiagnosticMessage(exception, "Executor failed."),
AgentId = agent?.AgentId,
AgentName = agent?.AgentName,
ExecutorId = executorFailed.ExecutorId,
ExceptionType = exception?.GetBaseException().GetType().Name,
};
return true;
}
case WorkflowWarningEvent workflowWarning:
diagnostic = new WorkflowDiagnosticEventDto
{
Type = "workflow-diagnostic",
RequestId = command.RequestId,
SessionId = command.SessionId,
Severity = "warning",
DiagnosticKind = workflowWarning is SubworkflowWarningEvent
? "subworkflow-warning"
: "workflow-warning",
Message = ResolveDiagnosticMessage(workflowWarning.Data as string, "Workflow warning."),
SubworkflowId = workflowWarning is SubworkflowWarningEvent subworkflowWarning
? subworkflowWarning.SubWorkflowId
: null,
};
return true;
case WorkflowErrorEvent workflowError:
{
Exception? exception = workflowError.Exception;
diagnostic = new WorkflowDiagnosticEventDto
{
Type = "workflow-diagnostic",
RequestId = command.RequestId,
SessionId = command.SessionId,
Severity = "error",
DiagnosticKind = workflowError is SubworkflowErrorEvent
? "subworkflow-error"
: "workflow-error",
Message = ResolveDiagnosticMessage(exception, "Workflow failed."),
SubworkflowId = workflowError is SubworkflowErrorEvent subworkflowError
? subworkflowError.SubworkflowId
: null,
ExceptionType = exception?.GetBaseException().GetType().Name,
};
return true;
}
default:
return false;
}
}
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(
exception?.GetBaseException().Message,
fallback);
}
private static string ResolveDiagnosticMessage(string? message, string fallback)
{
return string.IsNullOrWhiteSpace(message) ? fallback : message;
}
private static bool IsHandoffFunctionName(string? candidate)
{
return !string.IsNullOrWhiteSpace(candidate)
&& candidate.StartsWith(HandoffFunctionPrefix, StringComparison.Ordinal);
}
private static void TraceHandoff(RunTurnCommandDto command, string message)
{
if (!command.Workflow.IsOrchestrationMode("handoff"))
{
return;
}
Console.Error.WriteLine($"[aryx handoff] {message}");
}
}
@@ -1,289 +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" => AgentWorkflowBuilder.BuildSequential(pattern.Name, ResolveOrderedAgents(pattern)),
"sequential" => AgentWorkflowBuilder.BuildSequential(pattern.Name, ResolveOrderedAgents(pattern)),
"concurrent" => AgentWorkflowBuilder.BuildConcurrent(pattern.Name, ResolveOrderedAgents(pattern)),
"handoff" => BuildHandoffWorkflow(pattern),
"group-chat" => BuildGroupChatWorkflow(pattern),
"magentic" => throw new NotSupportedException(
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 = AgentWorkflowBuilder.CreateHandoffBuilderWith(entryAgent)
.WithHandoffInstructions(HandoffWorkflowGuidance.CreateWorkflowInstructions());
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();
}
private Workflow BuildGroupChatWorkflow(PatternDefinitionDto pattern)
{
int maximumIterations = pattern.MaxIterations <= 0 ? 5 : pattern.MaxIterations;
return AgentWorkflowBuilder
.CreateGroupChatBuilderWith(agents =>
new RoundRobinGroupChatManager(agents)
{
MaximumIterationCount = maximumIterations,
})
.AddParticipants(ResolveOrderedAgents(pattern).ToArray())
.Build();
}
private IReadOnlyList<AIAgent> ResolveOrderedAgents(PatternDefinitionDto pattern)
{
Dictionary<string, AIAgent> agentMap = BuildAgentMap(pattern);
List<AIAgent> orderedAgents = PatternGraphResolver.ResolveOrderedAgentIds(pattern)
.Select(agentId => agentMap.TryGetValue(agentId, out AIAgent? agent) ? agent : null)
.Where(agent => agent is not null)
.Cast<AIAgent>()
.ToList();
return orderedAgents.Count == Agents.Count ? orderedAgents : Agents;
}
private Dictionary<string, AIAgent> BuildAgentMap(PatternDefinitionDto pattern)
{
Dictionary<string, AIAgent> agentMap = new(StringComparer.Ordinal);
foreach ((PatternAgentDefinitionDto definition, AIAgent agent) in pattern.Agents.Zip(Agents))
{
agentMap[definition.Id] = agent;
}
return agentMap;
}
}
@@ -1,516 +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 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;
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);
break;
case ToolExecutionStartEvent toolExecutionStart
when !string.IsNullOrWhiteSpace(toolExecutionStart.Data?.ToolCallId)
&& !string.IsNullOrWhiteSpace(toolExecutionStart.Data?.ToolName):
ToolNamesByCallId[toolExecutionStart.Data.ToolCallId.Trim()] = toolExecutionStart.Data.ToolName.Trim();
break;
case AssistantReasoningDeltaEvent:
ActiveAgent = agent;
QueueThinkingIfNeeded(agent);
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;
}
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,
};
}
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);
}
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 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);
}
}
@@ -1,359 +0,0 @@
using System.Linq;
using Aryx.AgentHost.Contracts;
using GitHub.Copilot.SDK;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
namespace Aryx.AgentHost.Services;
public sealed class CopilotWorkflowRunner : 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();
public CopilotWorkflowRunner(PatternValidator patternValidator)
{
_patternValidator = patternValidator;
}
public async Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
RunTurnCommandDto command,
Func<TurnDeltaEventDto, Task> onDelta,
Func<SidecarEventDto, Task> onEvent,
Func<ApprovalRequestedEventDto, Task> onApproval,
Func<UserInputRequestedEventDto, Task> onUserInput,
Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired,
Func<ExitPlanModeRequestedEventDto, Task> onExitPlanMode,
CancellationToken cancellationToken)
{
PatternValidationIssueDto? validationError = _patternValidator.Validate(command.Pattern).FirstOrDefault();
if (validationError is not null)
{
throw new InvalidOperationException(validationError.Message);
}
CopilotTurnExecutionState state = new(command);
using CancellationTokenSource runCancellation =
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
try
{
await using CopilotAgentBundle bundle = await CopilotAgentBundle.CreateAsync(
command,
(agent, request, invocation) => _approvalCoordinator.RequestApprovalAsync(
command,
agent,
request,
invocation,
state.ToolNamesByCallId,
activity => EmitActivityAsync(command, state, activity, 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);
ConfigureHookLifecycleEventSuppression(state, bundle);
Workflow workflow = bundle.BuildWorkflow(command.Pattern);
List<ChatMessage> inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList();
WorkflowTranscriptProjector.AttachMessageMode(inputMessages, command.MessageMode);
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, inputMessages).ConfigureAwait(false);
await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
await foreach (WorkflowEvent evt in run.WatchStreamAsync(runCancellation.Token).ConfigureAwait(false))
{
bool shouldEndTurn = await HandleWorkflowEventAsync(command, evt, inputMessages, state, onDelta, onEvent)
.ConfigureAwait(false);
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
if (shouldEndTurn)
{
break;
}
}
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
return state.FinalizeCompletedMessages();
}
catch (OperationCanceledException) when (runCancellation.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
ExitPlanModeRequestedEventDto? exitPlanModeEvent =
_exitPlanModeCoordinator.ConsumePendingRequest(command.RequestId);
if (exitPlanModeEvent is null || !state.HasPendingExitPlanModeRequest)
{
throw;
}
await onExitPlanMode(exitPlanModeEvent).ConfigureAwait(false);
return state.FinalizeCompletedMessages();
}
finally
{
_approvalCoordinator.ClearRequestApprovals(command.RequestId);
}
}
internal static void ConfigureHookLifecycleEventSuppression(
CopilotTurnExecutionState state,
CopilotAgentBundle bundle)
{
ArgumentNullException.ThrowIfNull(state);
ArgumentNullException.ThrowIfNull(bundle);
state.SuppressHookLifecycleEvents = !bundle.HasConfiguredHooks;
}
private static async Task EmitPendingEventsAsync(
CopilotTurnExecutionState state,
Func<SidecarEventDto, Task> onEvent)
{
foreach (SidecarEventDto pendingEvent in state.DrainPendingEvents())
{
await onEvent(pendingEvent).ConfigureAwait(false);
}
}
private static async Task EmitPendingMcpOauthRequestsAsync(
CopilotTurnExecutionState state,
Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired)
{
foreach (McpOauthRequiredEventDto request in state.DrainPendingMcpOauthRequests())
{
await onMcpOAuthRequired(request).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);
}
private static async Task<bool> HandleWorkflowEventAsync(
RunTurnCommandDto command,
WorkflowEvent evt,
IReadOnlyList<ChatMessage> inputMessages,
CopilotTurnExecutionState state,
Func<TurnDeltaEventDto, Task> onDelta,
Func<SidecarEventDto, Task> onEvent)
{
if (evt is ExecutorInvokedEvent invoked)
{
if (AgentIdentityResolver.TryResolveKnownAgentIdentity(
command.Pattern,
invoked.ExecutorId,
out AgentIdentity invokedAgent))
{
TraceHandoff(command, $"Executor invoked: {invoked.ExecutorId} -> {invokedAgent.AgentName} ({invokedAgent.AgentId}).");
await state.EmitThinkingIfNeeded(invokedAgent, onEvent).ConfigureAwait(false);
}
else
{
TraceHandoff(command, $"Executor invoked without a known agent match: {invoked.ExecutorId}.");
}
return false;
}
if (evt is RequestInfoEvent requestInfo)
{
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
command,
requestInfo,
state.ActiveAgent,
state.ToolNamesByCallId);
if (activity is null)
{
bool requiresBoundary = WorkflowRequestInfoInterpreter.RequiresUserInputTurnBoundary(command, requestInfo);
TraceHandoff(
command,
$"Request info produced no activity for data type '{requestInfo.Request.Data.TypeId}'. Requires boundary: {requiresBoundary}.");
return requiresBoundary;
}
await EmitActivityAsync(command, state, activity, onEvent).ConfigureAwait(false);
return false;
}
if (evt is AgentResponseUpdateEvent update)
{
await HandleAgentResponseUpdateAsync(command, update, state, onDelta, onEvent).ConfigureAwait(false);
return false;
}
if (evt is ExecutorCompletedEvent completed)
{
if (AgentIdentityResolver.TryResolveObservedAgentIdentity(
command.Pattern,
completed.ExecutorId,
state.ActiveAgent,
out AgentIdentity completedAgent))
{
TraceHandoff(command, $"Executor completed: {completed.ExecutorId} -> {completedAgent.AgentName} ({completedAgent.AgentId}).");
state.ClearActiveAgentIfMatching(completedAgent);
}
else
{
TraceHandoff(command, $"Executor completed without a known agent match: {completed.ExecutorId}.");
}
return false;
}
if (evt is WorkflowOutputEvent outputEvent)
{
List<ChatMessage> allMessages = outputEvent.As<List<ChatMessage>>() ?? [];
state.UpdateCompletedMessages(allMessages, inputMessages);
}
return false;
}
private static async Task HandleAgentResponseUpdateAsync(
RunTurnCommandDto command,
AgentResponseUpdateEvent update,
CopilotTurnExecutionState state,
Func<TurnDeltaEventDto, Task> onDelta,
Func<SidecarEventDto, Task> onEvent)
{
AgentIdentity? updateAgent = null;
string authorName = update.ExecutorId;
string[] handoffFunctionCalls = update.Update.Contents
.OfType<FunctionCallContent>()
.Select(content => content.Name)
.Where(IsHandoffFunctionName)
.Distinct(StringComparer.Ordinal)
.ToArray();
if (state.TryResolveObservedAgentForMessage(update.Update.MessageId, out AgentIdentity observedMessageAgent))
{
updateAgent = observedMessageAgent;
authorName = observedMessageAgent.AgentName;
}
else if (AgentIdentityResolver.TryResolveObservedAgentIdentity(
command.Pattern,
update.ExecutorId,
state.ActiveAgent,
out AgentIdentity resolvedUpdateAgent))
{
updateAgent = resolvedUpdateAgent;
authorName = resolvedUpdateAgent.AgentName;
}
if (updateAgent.HasValue)
{
if (handoffFunctionCalls.Length > 0)
{
TraceHandoff(
command,
$"Agent response update from {updateAgent.Value.AgentName} ({updateAgent.Value.AgentId}) requested handoff via {string.Join(", ", handoffFunctionCalls)}.");
}
await state.EmitThinkingIfNeeded(updateAgent.Value, onEvent).ConfigureAwait(false);
}
else if (!string.IsNullOrEmpty(update.Update.Text) || handoffFunctionCalls.Length > 0)
{
TraceHandoff(
command,
$"Agent response update could not resolve agent for executor '{update.ExecutorId}' and message '{update.Update.MessageId ?? "<none>"}'.");
}
if (string.IsNullOrEmpty(update.Update.Text))
{
return;
}
string messageId = state.CreateMessageId(update.Update.MessageId);
(string _, string currentAuthorName, string currentContent) = state.AppendDelta(
messageId,
authorName,
update.Update.Text);
await onDelta(new TurnDeltaEventDto
{
Type = "turn-delta",
RequestId = command.RequestId,
SessionId = command.SessionId,
MessageId = messageId,
AuthorName = currentAuthorName,
ContentDelta = update.Update.Text,
Content = currentContent,
}).ConfigureAwait(false);
}
private static async Task EmitActivityAsync(
RunTurnCommandDto command,
CopilotTurnExecutionState state,
AgentActivityEventDto activity,
Func<SidecarEventDto, Task> onEvent)
{
state.ApplyEvent(activity);
TraceHandoff(
command,
$"Activity emitted: {activity.ActivityType} -> {activity.AgentName ?? activity.AgentId ?? "<unknown>"}.");
await onEvent(activity).ConfigureAwait(false);
if (string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal)
&& !string.IsNullOrWhiteSpace(activity.AgentId)
&& !string.IsNullOrWhiteSpace(activity.AgentName))
{
TraceHandoff(
command,
$"Promoting handoff target to thinking: {activity.AgentName} ({activity.AgentId}).");
await state.EmitThinkingIfNeeded(
new AgentIdentity(activity.AgentId, activity.AgentName),
onEvent).ConfigureAwait(false);
}
}
private static bool IsHandoffFunctionName(string? candidate)
{
return !string.IsNullOrWhiteSpace(candidate)
&& candidate.StartsWith(HandoffFunctionPrefix, StringComparison.Ordinal);
}
private static void TraceHandoff(RunTurnCommandDto command, string message)
{
if (!string.Equals(command.Pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase))
{
return;
}
Console.Error.WriteLine($"[aryx handoff] {message}");
}
}
@@ -8,26 +8,30 @@ internal static class HandoffWorkflowGuidance
{
return """
This workflow uses explicit handoffs to transfer ownership between agents.
If you are acting as the routing or triage agent, classify the request and hand it off to the best specialist as soon as ownership is clear.
If another agent should do the substantive work, perform an actual handoff instead of answering as though the handoff already happened.
For any substantive task, your next meaningful action must be the actual handoff rather than a plain-text promise to delegate later.
Do not claim that you delegated unless you actually executed the handoff.
The triage agent should route to the best specialist promptly once ownership is clear.
In a specialist workflow, the triage agent should hand off before inspecting files, calling tools, or drafting the substantive implementation.
If a specialist is appropriate, do not inspect files, call tools, draft the implementation, or produce the final user-facing answer before handing off.
Only answer directly when the request is pure triage or a minimal clarification is required before delegation.
Do not narrate a handoff in plain text without executing the handoff itself.
If you receive work as a specialist, own the substantive answer within your specialty and carry it through.
Do not push the work back to triage unless you are blocked or the request is clearly outside your specialty.
Specialists should complete the substantive work after handoff and only hand control back when the task needs re-routing, broader coordination, or is outside their specialty.
""";
}
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);
}
@@ -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();
}
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text.Json;
@@ -104,6 +105,7 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
try
{
Channel<AgentResponseUpdate> channel = Channel.CreateUnbounded<AgentResponseUpdate>();
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
using IDisposable subscription = copilotSession.On(evt =>
{
@@ -114,9 +116,19 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
break;
case AssistantMessageEvent assistantMessage:
TrackToolRequestNames(toolNamesByCallId, assistantMessage.Data?.ToolRequests);
channel.Writer.TryWrite(ConvertToAgentResponseUpdate(assistantMessage));
break;
case ToolExecutionCompleteEvent toolExecutionComplete:
AgentResponseUpdate? toolResultUpdate = ConvertToAgentResponseUpdate(toolExecutionComplete, toolNamesByCallId);
if (toolResultUpdate is not null)
{
channel.Writer.TryWrite(toolResultUpdate);
}
break;
case AssistantUsageEvent usageEvent:
channel.Writer.TryWrite(ConvertToAgentResponseUpdate(usageEvent));
break;
@@ -232,16 +244,6 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
continue;
}
// Only project handoff tool calls as FunctionCallContent for the Agent Framework.
// Other tool calls (ask_user, MCP tools, etc.) are resolved by the Copilot SDK
// internally and must not be surfaced, because AIAgentHostExecutor tracks every
// FunctionCallContent as an outstanding request. An unmatched request prevents
// the executor from emitting a TurnToken, which stalls group-chat advancement.
if (!IsHandoffToolName(toolRequest.Name))
{
continue;
}
contents.Add(new FunctionCallContent(
toolRequest.ToolCallId,
toolRequest.Name,
@@ -251,6 +253,26 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
return contents;
}
internal static FunctionResultContent? TryCreateToolResultContent(
ToolExecutionCompleteEvent toolExecutionComplete,
string? toolName = null)
{
// Regular Copilot tools need their result projected back into AF so the function call
// remains part of workflow-visible history. Handoff tools are finalized separately by
// HandoffAgentExecutor, which already injects its own "Transferred." result.
string? toolCallId = toolExecutionComplete.Data?.ToolCallId?.Trim();
if (string.IsNullOrWhiteSpace(toolCallId) || IsHandoffToolName(toolName))
{
return null;
}
string result = ResolveToolResultText(toolExecutionComplete.Data);
return new FunctionResultContent(toolCallId, result)
{
RawRepresentation = toolExecutionComplete,
};
}
private static bool IsHandoffToolName(string? name)
{
return !string.IsNullOrWhiteSpace(name)
@@ -441,6 +463,36 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
};
}
private AgentResponseUpdate? ConvertToAgentResponseUpdate(
ToolExecutionCompleteEvent toolExecutionComplete,
ConcurrentDictionary<string, string> toolNamesByCallId)
{
string? toolCallId = toolExecutionComplete.Data?.ToolCallId?.Trim();
if (string.IsNullOrWhiteSpace(toolCallId))
{
return null;
}
string? toolName = null;
if (toolNamesByCallId.TryRemove(toolCallId, out string? trackedToolName))
{
toolName = trackedToolName;
}
FunctionResultContent? toolResult = TryCreateToolResultContent(toolExecutionComplete, toolName);
if (toolResult is null)
{
return null;
}
return new AgentResponseUpdate(ChatRole.Tool, [toolResult])
{
AgentId = Id,
MessageId = toolCallId,
CreatedAt = toolExecutionComplete.Timestamp,
};
}
private AgentResponseUpdate ConvertToAgentResponseUpdate(SessionEvent sessionEvent)
{
AIContent content = new()
@@ -455,6 +507,45 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
};
}
private static void TrackToolRequestNames(
ConcurrentDictionary<string, string> toolNamesByCallId,
AssistantMessageDataToolRequestsItem[]? toolRequests)
{
if (toolRequests is not { Length: > 0 })
{
return;
}
foreach (AssistantMessageDataToolRequestsItem toolRequest in toolRequests)
{
string? toolCallId = toolRequest.ToolCallId?.Trim();
string? toolName = toolRequest.Name?.Trim();
if (string.IsNullOrWhiteSpace(toolCallId) || string.IsNullOrWhiteSpace(toolName))
{
continue;
}
toolNamesByCallId[toolCallId] = toolName;
}
}
private static string ResolveToolResultText(ToolExecutionCompleteData? toolExecutionCompleteData)
{
if (toolExecutionCompleteData is null)
{
return string.Empty;
}
if (toolExecutionCompleteData.Success)
{
return toolExecutionCompleteData.Result?.Content
?? toolExecutionCompleteData.Result?.DetailedContent
?? string.Empty;
}
return toolExecutionCompleteData.Error?.Message ?? string.Empty;
}
private static Dictionary<string, object?>? ParseToolArguments(object? arguments)
{
if (arguments is null)
@@ -0,0 +1,331 @@
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));
agents.Add(agent);
disposables.Add(agent);
}
// The bundle owns the shared client — disposed after all agents.
disposables.Add(sharedClient);
}
CopilotAgentBundle bundle = new(agents, hasConfiguredHooks: !configuredHooks.IsEmpty);
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();
}
}
@@ -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);
}
@@ -21,6 +21,24 @@ 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)
{
["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,
};
private readonly ConcurrentDictionary<string, PendingApprovalRequest> _pendingApprovals = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> _requestApprovedTools = new(StringComparer.Ordinal);
@@ -49,10 +67,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)
{
@@ -61,7 +79,7 @@ internal sealed class CopilotApprovalCoordinator
agent,
request,
invocation,
toolNamesByCallId,
toolCalls,
onActivity: null,
onApproval,
cancellationToken)
@@ -70,17 +88,17 @@ 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? toolName = ResolveApprovalToolName(request, toolCalls);
string? autoApprovedToolName = ResolveAutoApprovedToolName(request);
string? mcpServerApprovalKey = ResolveMcpServerApprovalKey(request);
string? mcpServerApprovalKey = ResolveMcpServerApprovalKey(request, command.Tooling?.McpServers);
string? approvalCacheKey = ResolveApprovalCacheKey(toolName, autoApprovedToolName);
AgentActivityEventDto? fileChangeActivity = BuildToolCallFileChangeActivity(command, agent, request, toolName);
@@ -90,7 +108,7 @@ internal sealed class CopilotApprovalCoordinator
}
if (IsToolApprovedForRequest(command.RequestId, approvalCacheKey)
|| !RequiresToolCallApproval(command.Pattern.ApprovalPolicy, agent.Id, toolName, autoApprovedToolName, mcpServerApprovalKey))
|| !RequiresToolCallApproval(command.Workflow.Settings.ApprovalPolicy, agent.GetAgentId(), toolName, autoApprovedToolName, mcpServerApprovalKey))
{
return CreateApprovalResult(PermissionRequestResultKind.Approved);
}
@@ -131,16 +149,16 @@ internal sealed class CopilotApprovalCoordinator
internal static ApprovalRequestedEventDto BuildPermissionApprovalEvent(
RunTurnCommandDto command,
PatternAgentDefinitionDto agent,
WorkflowNodeDto agent,
PermissionRequest request,
PermissionInvocation invocation,
string approvalId,
string? toolName)
{
string permissionKind = string.IsNullOrWhiteSpace(request.Kind)
? "tool access"
: request.Kind.Trim();
string agentName = string.IsNullOrWhiteSpace(agent.Name) ? agent.Id : agent.Name;
string permissionKind = ResolvePermissionKind(request, command.Tooling?.McpServers);
string agentId = agent.GetAgentId();
string agentName = agent.GetAgentName();
string? sessionId = NormalizeOptionalString(invocation.SessionId);
string? normalizedToolName = NormalizeOptionalString(toolName);
string? requestedUrl = request is PermissionRequestUrl urlRequest
@@ -174,19 +192,19 @@ 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,
Title = title,
Detail = detail,
PermissionDetail = BuildPermissionDetail(request),
PermissionDetail = BuildPermissionDetail(request, command.Tooling?.McpServers),
};
}
internal static AgentActivityEventDto? BuildToolCallFileChangeActivity(
RunTurnCommandDto command,
PatternAgentDefinitionDto agent,
WorkflowNodeDto agent,
PermissionRequest request,
string? toolName)
{
@@ -201,14 +219,15 @@ internal sealed class CopilotApprovalCoordinator
return null;
}
string agentName = string.IsNullOrWhiteSpace(agent.Name) ? agent.Id : agent.Name;
string agentId = agent.GetAgentId();
string agentName = agent.GetAgentName();
return new AgentActivityEventDto
{
Type = "agent-activity",
RequestId = command.RequestId,
SessionId = command.SessionId,
ActivityType = ToolCallingActivityType,
AgentId = NormalizeOptionalString(agent.Id),
AgentId = NormalizeOptionalString(agentId),
AgentName = NormalizeOptionalString(agentName),
ToolName = NormalizeOptionalString(toolName),
ToolCallId = NormalizeOptionalString(write.ToolCallId),
@@ -224,7 +243,9 @@ internal sealed class CopilotApprovalCoordinator
};
}
internal static PermissionDetailDto BuildPermissionDetail(PermissionRequest request)
internal static PermissionDetailDto BuildPermissionDetail(
PermissionRequest request,
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers = null)
{
ArgumentNullException.ThrowIfNull(request);
@@ -281,12 +302,7 @@ internal sealed class CopilotApprovalCoordinator
ToolDescription = NormalizeOptionalString(customTool.ToolDescription),
Args = customTool.Args,
},
PermissionRequestHook hook => new PermissionDetailDto
{
Kind = HookPermissionKind,
Args = hook.ToolArgs,
HookMessage = NormalizeOptionalString(hook.HookMessage),
},
PermissionRequestHook hook => BuildHookPermissionDetail(hook, configuredMcpServers),
_ => new PermissionDetailDto
{
Kind = NormalizeOptionalString(request.Kind) ?? "unknown",
@@ -323,15 +339,15 @@ internal sealed class CopilotApprovalCoordinator
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)
{
@@ -388,10 +404,10 @@ 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);
}
@@ -402,15 +418,45 @@ internal sealed class CopilotApprovalCoordinator
private const string McpServerApprovalPrefix = "mcp_server:";
private static string? ResolveMcpServerApprovalKey(PermissionRequest request)
private static string? ResolveMcpServerApprovalKey(
PermissionRequest request,
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers)
{
if (request is not PermissionRequestMcp mcp)
return request switch
{
PermissionRequestMcp mcp => BuildMcpServerApprovalKey(mcp.ServerName),
PermissionRequestHook hook => ResolveHookMcpServerApprovalKey(hook.ToolName, configuredMcpServers),
_ => null,
};
}
internal static string? BuildMcpServerApprovalKey(string? serverName)
{
string? normalizedServerName = NormalizeOptionalString(serverName);
return normalizedServerName is not null ? $"{McpServerApprovalPrefix}{normalizedServerName}" : null;
}
internal static string? ResolveHookMcpServerApprovalKey(
string? toolName,
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers)
=> BuildMcpServerApprovalKey(ResolveHookMcpServerName(toolName, configuredMcpServers));
internal static string? ResolveHookMcpServerName(
string? toolName,
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers)
{
string? normalizedToolName = NormalizeOptionalString(toolName);
if (normalizedToolName is null || configuredMcpServers is null || configuredMcpServers.Count == 0)
{
return null;
}
string? serverName = NormalizeOptionalString(mcp.ServerName);
return serverName is not null ? $"{McpServerApprovalPrefix}{serverName}" : null;
return configuredMcpServers
.Select(ResolveConfiguredMcpServerName)
.OfType<string>()
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderByDescending(static serverName => serverName.Length)
.FirstOrDefault(serverName => MatchesHookMcpServerToolName(normalizedToolName, serverName));
}
private static string? ResolveApprovalCacheKey(
@@ -434,16 +480,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;
}
@@ -476,10 +522,103 @@ internal sealed class CopilotApprovalCoordinator
PermissionRequestWrite => WritePermissionKind,
PermissionRequestRead => ReadPermissionKind,
PermissionRequestMemory => StoreMemoryToolName,
PermissionRequestHook hook => ResolveHookToolCategory(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;
}
private static string ResolvePermissionKind(
PermissionRequest request,
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers)
{
string permissionKind = string.IsNullOrWhiteSpace(request.Kind)
? "tool access"
: request.Kind.Trim();
if (request is not PermissionRequestHook hook)
{
return permissionKind;
}
string? resolvedCategory = ResolveHookToolCategory(hook.ToolName);
if (resolvedCategory is not null)
{
return resolvedCategory;
}
return ResolveHookMcpServerName(hook.ToolName, configuredMcpServers) is not null
? McpPermissionKind
: permissionKind;
}
private static PermissionDetailDto BuildHookPermissionDetail(
PermissionRequestHook hook,
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers)
{
string? serverName = ResolveHookMcpServerName(hook.ToolName, configuredMcpServers);
if (serverName is null)
{
return new PermissionDetailDto
{
Kind = HookPermissionKind,
Args = hook.ToolArgs,
HookMessage = NormalizeOptionalString(hook.HookMessage),
};
}
return new PermissionDetailDto
{
Kind = McpPermissionKind,
ServerName = serverName,
ToolTitle = ResolveHookMcpToolTitle(hook.ToolName, serverName),
Args = hook.ToolArgs,
};
}
private static string? ResolveConfiguredMcpServerName(RunTurnMcpServerConfigDto configuredServer)
=> NormalizeOptionalString(configuredServer.Name) ?? NormalizeOptionalString(configuredServer.Id);
private static bool MatchesHookMcpServerToolName(string toolName, string serverName)
{
if (string.Equals(toolName, serverName, StringComparison.OrdinalIgnoreCase))
{
return true;
}
return toolName.StartsWith($"{serverName}-", StringComparison.OrdinalIgnoreCase);
}
private static string? ResolveHookMcpToolTitle(string? toolName, string serverName)
{
string? normalizedToolName = NormalizeOptionalString(toolName);
if (normalizedToolName is null)
{
return null;
}
string prefix = $"{serverName}-";
if (!normalizedToolName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
return normalizedToolName;
}
string strippedToolName = normalizedToolName[prefix.Length..];
return string.IsNullOrWhiteSpace(strippedToolName)
? normalizedToolName
: strippedToolName;
}
private static bool MatchesAutoApprovedTool(
IReadOnlyList<string> autoApprovedToolNames,
string? toolName,
@@ -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();
}
}
@@ -11,7 +11,7 @@ internal sealed class CopilotExitPlanModeCoordinator
public ExitPlanModeRequestedEventDto RecordExitPlanModeRequest(
RunTurnCommandDto command,
PatternAgentDefinitionDto agent,
WorkflowNodeDto agent,
ExitPlanModeRequestedEvent request)
{
ArgumentNullException.ThrowIfNull(command);
@@ -33,7 +33,7 @@ internal sealed class CopilotExitPlanModeCoordinator
internal static ExitPlanModeRequestedEventDto BuildExitPlanModeRequestedEvent(
RunTurnCommandDto command,
PatternAgentDefinitionDto agent,
WorkflowNodeDto agent,
ExitPlanModeRequestedEvent request)
{
ArgumentNullException.ThrowIfNull(command);
@@ -45,8 +45,8 @@ internal sealed class CopilotExitPlanModeCoordinator
string exitPlanId = NormalizeOptionalString(requestData.RequestId)
?? throw new InvalidOperationException("Exit plan mode request ID is required.");
string? normalizedAgentId = NormalizeOptionalString(agent.Id);
string? normalizedAgentName = NormalizeOptionalString(agent.Name) ?? normalizedAgentId;
string? normalizedAgentId = NormalizeOptionalString(agent.GetAgentId());
string? normalizedAgentName = NormalizeOptionalString(agent.GetAgentName()) ?? normalizedAgentId;
return new ExitPlanModeRequestedEventDto
{
@@ -7,7 +7,7 @@ internal sealed class CopilotMcpOAuthCoordinator
{
public McpOauthRequiredEventDto BuildMcpOauthRequiredEvent(
RunTurnCommandDto command,
PatternAgentDefinitionDto agent,
WorkflowNodeDto agent,
McpOauthRequiredEvent request)
{
ArgumentNullException.ThrowIfNull(command);
@@ -19,8 +19,8 @@ internal sealed class CopilotMcpOAuthCoordinator
string oauthRequestId = NormalizeOptionalString(requestData.RequestId)
?? throw new InvalidOperationException("MCP OAuth request ID is required.");
string? normalizedAgentId = NormalizeOptionalString(agent.Id);
string? normalizedAgentName = NormalizeOptionalString(agent.Name) ?? normalizedAgentId;
string? normalizedAgentId = NormalizeOptionalString(agent.GetAgentId());
string? normalizedAgentName = NormalizeOptionalString(agent.GetAgentName()) ?? normalizedAgentId;
return new McpOauthRequiredEventDto
{
@@ -11,20 +11,36 @@ internal static class CopilotSessionHooks
private const string AllowDecision = "allow";
private const string AskDecision = "ask";
private const string DenyDecision = "deny";
private const string ExitPlanModeToolName = "exit_plan_mode";
private const string FetchCopilotCliDocumentationToolName = "fetch_copilot_cli_documentation";
private const string HandoffToolPrefix = "handoff_to_";
private const string ListAgentsToolName = "list_agents";
private const string ReadAgentToolName = "read_agent";
private const string ReportIntentToolName = "report_intent";
private const string SkillToolName = "skill";
private const string SqlToolName = "sql";
private const string TaskToolName = "task";
private const string TaskCompleteToolName = "task_complete";
private const string UpdateTodoToolName = "update_todo";
private static readonly HashSet<string> AlwaysAllowedToolNames = new(StringComparer.OrdinalIgnoreCase)
{
AskUserToolName,
ExitPlanModeToolName,
FetchCopilotCliDocumentationToolName,
ListAgentsToolName,
ReadAgentToolName,
ReportIntentToolName,
SkillToolName,
SqlToolName,
TaskToolName,
TaskCompleteToolName,
UpdateTodoToolName,
};
private static readonly JsonSerializerOptions HookJsonOptions = CreateHookJsonOptions();
public static SessionHooks Create(
RunTurnCommandDto command,
PatternAgentDefinitionDto agentDefinition,
WorkflowNodeDto agentDefinition,
ResolvedHookSet? configuredHooks = null,
IHookCommandRunner? hookCommandRunner = null)
{
@@ -46,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)
@@ -220,7 +236,7 @@ internal static class CopilotSessionHooks
private static PreToolUseHookOutput CreateApprovalPolicyOutput(
RunTurnCommandDto command,
PatternAgentDefinitionDto agentDefinition,
WorkflowNodeDto agentDefinition,
PreToolUseHookInput input)
{
string? toolName = Normalize(input.ToolName);
@@ -232,11 +248,17 @@ internal static class CopilotSessionHooks
};
}
bool requiresApproval = CopilotApprovalCoordinator.RequiresToolCallApproval(
command.Pattern.ApprovalPolicy,
agentDefinition.Id,
string? autoApprovedToolName = CopilotApprovalCoordinator.ResolveHookToolCategory(toolName) ?? toolName;
string? mcpServerApprovalKey = CopilotApprovalCoordinator.ResolveHookMcpServerApprovalKey(
toolName,
toolName);
command.Tooling?.McpServers);
bool requiresApproval = CopilotApprovalCoordinator.RequiresToolCallApproval(
command.Workflow.Settings.ApprovalPolicy,
agentDefinition.GetAgentId(),
toolName,
autoApprovedToolName,
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();
}
}
}
@@ -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);
}
}
@@ -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;
@@ -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);
}
@@ -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;
}
}
@@ -0,0 +1,858 @@
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(string id = "OutputMessages")
: Executor(id, declareCrossRunShareable: true), IResettableExecutor
{
public const string ExecutorId = "OutputMessages";
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder 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>>();
}
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,
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;
}
internal sealed class WorkflowAggregateTurnMessagesExecutor(string id)
: ChatProtocolExecutor(id, s_options, declareCrossRunShareable: true), IResettableExecutor
{
private static readonly ChatProtocolExecutorOptions s_options = new() { AutoSendTurnToken = false };
protected override ValueTask TakeTurnAsync(
List<ChatMessage> messages,
IWorkflowContext context,
bool? emitEvents,
CancellationToken cancellationToken = default)
=> context.SendMessageAsync(messages, cancellationToken: cancellationToken);
ValueTask IResettableExecutor.ResetAsync() => this.ResetAsync();
}
internal sealed class WorkflowConcurrentEndExecutor : Executor, IResettableExecutor
{
public const string ExecutorId = "ConcurrentEnd";
private readonly int _expectedInputs;
private readonly Func<IList<List<ChatMessage>>, List<ChatMessage>> _aggregator;
private List<List<ChatMessage>> _allResults;
private int _remaining;
public WorkflowConcurrentEndExecutor(
int expectedInputs,
Func<IList<List<ChatMessage>>, List<ChatMessage>> aggregator)
: base(ExecutorId)
{
_expectedInputs = expectedInputs;
_aggregator = aggregator;
_allResults = new List<List<ChatMessage>>(expectedInputs);
_remaining = expectedInputs;
}
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
protocolBuilder.RouteBuilder.AddHandler<List<ChatMessage>>(async (messages, context, cancellationToken) =>
{
bool done;
lock (_allResults)
{
_allResults.Add(messages);
done = --_remaining == 0;
}
if (!done)
{
return;
}
_remaining = _expectedInputs;
List<List<ChatMessage>> results = _allResults;
_allResults = new List<List<ChatMessage>>(_expectedInputs);
await context.YieldOutputAsync(_aggregator(results), cancellationToken).ConfigureAwait(false);
});
return protocolBuilder.YieldsOutput<List<ChatMessage>>();
}
public ValueTask ResetAsync()
{
_allResults = new List<List<ChatMessage>>(_expectedInputs);
_remaining = _expectedInputs;
return default;
}
}
internal sealed class WorkflowRoundRobinGroupChatHost(
string id,
AIAgent[] agents,
Dictionary<AIAgent, ExecutorBinding> agentMap,
int maximumIterations)
: ChatProtocolExecutor(id, s_options), IResettableExecutor
{
private static readonly ChatProtocolExecutorOptions s_options = new()
{
StringMessageChatRole = ChatRole.User,
AutoSendTurnToken = false,
};
private readonly AIAgent[] _agents = agents;
private readonly Dictionary<AIAgent, ExecutorBinding> _agentMap = agentMap;
private readonly int _maximumIterations = maximumIterations;
private int _iterationCount;
private int _nextIndex;
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
=> base.ConfigureProtocol(protocolBuilder).YieldsOutput<List<ChatMessage>>();
protected override async ValueTask TakeTurnAsync(
List<ChatMessage> messages,
IWorkflowContext context,
bool? emitEvents,
CancellationToken cancellationToken = default)
{
if (_iterationCount < _maximumIterations)
{
AIAgent nextAgent = _agents[_nextIndex];
_nextIndex = (_nextIndex + 1) % _agents.Length;
if (_agentMap.TryGetValue(nextAgent, out ExecutorBinding? executor))
{
_iterationCount++;
await context.SendMessageAsync(messages, executor.Id, cancellationToken).ConfigureAwait(false);
await context.SendMessageAsync(new TurnToken(emitEvents), executor.Id, cancellationToken).ConfigureAwait(false);
return;
}
}
_iterationCount = 0;
_nextIndex = 0;
await context.YieldOutputAsync(messages, cancellationToken).ConfigureAwait(false);
}
protected override ValueTask ResetAsync()
{
_iterationCount = 0;
_nextIndex = 0;
return base.ResetAsync();
}
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,18 +53,23 @@ 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,
};
}
private static AgentActivityEventDto CreateToolCallingActivity(
private static AgentActivityEventDto? CreateToolCallingActivity(
RunTurnCommandDto command,
AgentIdentity activeAgent,
ToolRequestInterpretation tool,
ConcurrentDictionary<string, string> toolNamesByCallId)
ToolCallRegistry toolCalls)
{
TrackToolCallId(toolNamesByCallId, tool.ToolCallId, tool.ToolName);
if (!toolCalls.TryRecordToolRequest(tool.ToolCallId, tool.ToolName, tool.ToolArguments))
{
return null;
}
return new AgentActivityEventDto
{
@@ -73,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)
{
@@ -123,7 +121,8 @@ internal static class WorkflowRequestInfoInterpreter
}
agent = AgentIdentityResolver.ResolveAgentIdentity(
pattern,
command.Workflow,
command.WorkflowLibrary,
target.Id,
target.Name);
return !string.IsNullOrWhiteSpace(agent.AgentName);
@@ -132,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;
}
@@ -173,6 +178,7 @@ internal static class WorkflowRequestInfoInterpreter
{
toolName = CodeInterpreterToolName;
toolCallId = NormalizeOptionalString(codeInterpreterToolCall.CallId);
toolArguments = NormalizeCodeInterpreterToolArguments(codeInterpreterToolCall);
return true;
}
@@ -180,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();
@@ -203,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).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);
@@ -54,66 +33,45 @@ public sealed class AgentInstructionComposerTests
}
[Fact]
public void Compose_StrengthensHandoffTriageInstructions()
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.Contains("routing gate", instructions, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Do not inspect files", instructions, StringComparison.OrdinalIgnoreCase);
Assert.Contains("actual handoff", instructions, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Do not claim that you handed work off", instructions, StringComparison.OrdinalIgnoreCase);
Assert.Equal("You triage requests and must hand them off to the most appropriate specialist.", instructions);
Assert.DoesNotContain("routing", instructions, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("actual handoff", instructions, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Compose_StrengthensHandoffSpecialistInstructions()
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.Contains("Once the triage agent hands work to you", instructions, StringComparison.OrdinalIgnoreCase);
Assert.Contains("own the substantive answer", instructions, StringComparison.OrdinalIgnoreCase);
Assert.Equal("You focus on navigation, UX, and interaction details.", instructions);
Assert.DoesNotContain("triage agent", instructions, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("substantive answer", instructions, StringComparison.OrdinalIgnoreCase);
}
[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>
@@ -4,12 +4,31 @@ using Aryx.AgentHost.Contracts;
using GitHub.Copilot.SDK;
using Aryx.AgentHost.Services;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
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()
{
@@ -53,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()
{
@@ -111,6 +158,133 @@ public sealed class CopilotAgentBundleTests
Assert.Throws<NotSupportedException>(() => AryxCopilotAgent.CreateConfiguredSessionConfig(new SessionConfig(), options));
}
[Fact]
public void CreateHandoffWorkflowBuilder_ExplicitlyUsesHandoffOnlyFiltering()
{
ChatClientAgent entryAgent = CreateChatClientAgent("agent-1", "Primary");
HandoffWorkflowBuilder builder = CopilotAgentBundle.CreateHandoffWorkflowBuilder(entryAgent);
FieldInfo field = GetInstanceField(
typeof(HandoffWorkflowBuilder),
"_toolCallFilteringBehavior",
"Expected HandoffWorkflowBuilder to expose a filtering field.");
HandoffToolCallFilteringBehavior behavior = Assert.IsType<HandoffToolCallFilteringBehavior>(field.GetValue(builder));
Assert.Equal(HandoffToolCallFilteringBehavior.HandoffOnly, behavior);
Assert.Equal(HandoffWorkflowGuidance.CreateWorkflowInstructions(), builder.HandoffInstructions);
}
[Fact]
public void CreateHandoffWorkflowBuilder_MapsConfiguredFilteringAndInstructions()
{
ChatClientAgent entryAgent = CreateChatClientAgent("agent-1", "Primary");
HandoffWorkflowBuilder builder = CopilotAgentBundle.CreateHandoffWorkflowBuilder(
entryAgent,
new HandoffModeSettingsDto
{
ToolCallFiltering = "all",
ReturnToPrevious = true,
HandoffInstructions = "Use custom delegation guidance.",
});
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(HandoffToolCallFilteringBehavior.All, filteringField.GetValue(builder));
Assert.Equal(true, returnToPreviousField.GetValue(builder));
Assert.Equal("Use custom delegation guidance.", builder.HandoffInstructions);
}
[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]
public void ConvertToolRequestsToFunctionCalls_MapsCallIdsNamesAndArguments()
{
@@ -136,7 +310,7 @@ public sealed class CopilotAgentBundleTests
}
[Fact]
public void ConvertToolRequestsToFunctionCalls_SkipsNonHandoffToolCalls()
public void ConvertToolRequestsToFunctionCalls_MapsNonHandoffToolCalls()
{
AssistantMessageDataToolRequestsItem[] toolRequests =
{
@@ -148,9 +322,99 @@ public sealed class CopilotAgentBundleTests
IReadOnlyList<FunctionCallContent> result = AryxCopilotAgent.ConvertToolRequestsToFunctionCalls(toolRequests);
FunctionCallContent single = Assert.Single(result);
Assert.Equal("call-003", single.CallId);
Assert.Equal("handoff_to_reviewer", single.Name);
Assert.Collection(
result,
functionCall =>
{
Assert.Equal("call-001", functionCall.CallId);
Assert.Equal("ask_user", functionCall.Name);
},
functionCall =>
{
Assert.Equal("call-002", functionCall.CallId);
Assert.Equal("web_fetch", functionCall.Name);
},
functionCall =>
{
Assert.Equal("call-003", functionCall.CallId);
Assert.Equal("handoff_to_reviewer", functionCall.Name);
},
functionCall =>
{
Assert.Equal("call-004", functionCall.CallId);
Assert.Equal("grep", functionCall.Name);
});
}
[Fact]
public void TryCreateToolResultContent_UsesSdkResultContentForNonHandoffTools()
{
ToolExecutionCompleteEvent toolExecutionComplete = new()
{
Data = new ToolExecutionCompleteData
{
ToolCallId = "call-123",
Success = true,
Result = new ToolExecutionCompleteDataResult
{
Content = "Search complete.",
DetailedContent = "Search complete with extra context.",
},
},
};
FunctionResultContent? toolResult = AryxCopilotAgent.TryCreateToolResultContent(toolExecutionComplete, "rg");
Assert.NotNull(toolResult);
Assert.Equal("call-123", toolResult.CallId);
Assert.Equal("Search complete.", Assert.IsType<string>(toolResult.Result));
Assert.Same(toolExecutionComplete, toolResult.RawRepresentation);
}
[Fact]
public void TryCreateToolResultContent_UsesSdkErrorMessageForFailedTools()
{
ToolExecutionCompleteEvent toolExecutionComplete = new()
{
Data = new ToolExecutionCompleteData
{
ToolCallId = "call-456",
Success = false,
Error = new ToolExecutionCompleteDataError
{
Message = "Permission denied.",
},
},
};
FunctionResultContent? toolResult = AryxCopilotAgent.TryCreateToolResultContent(toolExecutionComplete, "view");
Assert.NotNull(toolResult);
Assert.Equal("call-456", toolResult.CallId);
Assert.Equal("Permission denied.", Assert.IsType<string>(toolResult.Result));
}
[Fact]
public void TryCreateToolResultContent_SkipsHandoffTools()
{
ToolExecutionCompleteEvent toolExecutionComplete = new()
{
Data = new ToolExecutionCompleteData
{
ToolCallId = "call-789",
Success = true,
Result = new ToolExecutionCompleteDataResult
{
Content = "Transferred.",
},
},
};
FunctionResultContent? toolResult = AryxCopilotAgent.TryCreateToolResultContent(
toolExecutionComplete,
"handoff_to_reviewer");
Assert.Null(toolResult);
}
[Fact]
@@ -220,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);
@@ -260,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()
{
@@ -294,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 =
[
@@ -310,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
{
@@ -345,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))
@@ -356,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(
@@ -369,8 +658,199 @@ public sealed class CopilotAgentBundleTests
CreateTool().JsonSchema);
}
private static IReadOnlyList<AIAgent> CreateAgents(int count)
=> Enumerable.Range(1, count)
.Select(index => (AIAgent)CreateChatClientAgent($"agent-{index}", $"Agent {index}"))
.ToArray();
private static WorkflowDefinitionDto CreateWorkflow(
string mode,
int agentCount,
ApprovalPolicyDto? approvalPolicy = null,
OrchestrationModeSettingsDto? modeSettings = null,
string? name = null,
string? description = null)
{
return new WorkflowDefinitionDto
{
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",
},
};
}
private static ChatClientAgent CreateChatClientAgent(string id, string name)
{
return new ChatClientAgent(
new StubChatClient(),
id,
name,
"Stub agent for handoff builder tests.",
[],
null!,
null!);
}
private sealed class ToolTarget
{
public string Echo() => "ok";
}
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,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
@@ -108,14 +108,22 @@ public sealed class CopilotSessionHooksTests
[Theory]
[InlineData("ask_user")]
[InlineData("exit_plan_mode")]
[InlineData("fetch_copilot_cli_documentation")]
[InlineData("list_agents")]
[InlineData("read_agent")]
[InlineData("report_intent")]
[InlineData("skill")]
[InlineData("sql")]
[InlineData("task")]
[InlineData("task_complete")]
[InlineData("update_todo")]
[InlineData("handoff_to_2")]
[InlineData("handoff_to_specialist")]
public async Task Create_PreToolUseAutoAllowsInfrastructureTools(string toolName)
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
@@ -127,6 +135,76 @@ public sealed class CopilotSessionHooksTests
Assert.Equal("allow", decision?.PermissionDecision);
}
[Fact]
public async Task Create_PreToolUseKeepsStoreMemoryUnderApprovalPolicy()
{
RunTurnCommandDto command = CreateCommandWithToolApproval();
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
new PreToolUseHookInput
{
ToolName = "store_memory",
},
null!);
Assert.Equal("ask", decision?.PermissionDecision);
}
[Theory]
[InlineData("view", "read")]
[InlineData("grep", "read")]
[InlineData("edit", "write")]
[InlineData("powershell", "shell")]
public async Task Create_PreToolUseAutoAllowsWhenCategoryIsApproved(string toolName, string category)
{
RunTurnCommandDto command = CreateCommandWithAutoApprovedCategory(category);
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
new PreToolUseHookInput
{
ToolName = toolName,
},
null!);
Assert.Equal("allow", decision?.PermissionDecision);
}
[Fact]
public async Task Create_PreToolUseAutoAllowsWhenMcpServerIsApproved()
{
RunTurnCommandDto command = CreateCommandWithConfiguredMcpServers(
["icm-mcp"],
["mcp_server:icm-mcp"]);
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
new PreToolUseHookInput
{
ToolName = "icm-mcp-get_incident_details_by_id",
},
null!);
Assert.Equal("allow", decision?.PermissionDecision);
}
[Fact]
public async Task Create_PreToolUseRequiresApprovalWhenMcpServerIsNotApproved()
{
RunTurnCommandDto command = CreateCommandWithConfiguredMcpServers(["icm-mcp"]);
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
new PreToolUseHookInput
{
ToolName = "icm-mcp-get_incident_details_by_id",
},
null!);
Assert.Equal("ask", decision?.PermissionDecision);
}
[Fact]
public async Task Create_RunsConfiguredNonPreToolHooks()
{
@@ -141,7 +219,7 @@ public sealed class CopilotSessionHooksTests
ErrorOccurred = [CreateHookCommand("error-hook")],
};
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], configuredHooks, runner);
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], configuredHooks, runner);
await hooks.OnSessionStart!(
new SessionStartHookInput
@@ -215,7 +293,7 @@ public sealed class CopilotSessionHooksTests
public async Task Create_WithoutConfiguredFileHooksPreservesExistingApprovalBehavior()
{
RunTurnCommandDto command = CreateCommandWithoutApprovalRules();
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
new PreToolUseHookInput
@@ -234,34 +312,17 @@ public sealed class CopilotSessionHooksTests
RequestId = "turn-1",
SessionId = "session-1",
ProjectPath = @"C:\workspace\project",
Pattern = new PatternDefinitionDto
Workflow = CreateWorkflow(new ApprovalPolicyDto
{
Id = "pattern-1",
Name = "Pattern",
Mode = "single",
Availability = "available",
ApprovalPolicy = new ApprovalPolicyDto
{
Rules =
[
new ApprovalCheckpointRuleDto
{
Kind = "tool-call",
AgentIds = ["agent-1"],
},
],
},
Agents =
Rules =
[
new PatternAgentDefinitionDto
new ApprovalCheckpointRuleDto
{
Id = "agent-1",
Name = "Primary",
Model = "gpt-5.4",
Instructions = "Help.",
Kind = "tool-call",
AgentIds = ["agent-1"],
},
],
},
}),
};
}
@@ -273,18 +334,95 @@ public sealed class CopilotSessionHooksTests
RequestId = command.RequestId,
SessionId = command.SessionId,
ProjectPath = command.ProjectPath,
Pattern = new PatternDefinitionDto
Workflow = CreateWorkflow(new ApprovalPolicyDto()),
};
}
private static RunTurnCommandDto CreateCommandWithAutoApprovedCategory(string category)
{
return new RunTurnCommandDto
{
RequestId = "turn-1",
SessionId = "session-1",
ProjectPath = @"C:\workspace\project",
Workflow = CreateWorkflow(new ApprovalPolicyDto
{
Id = command.Pattern.Id,
Name = command.Pattern.Name,
Mode = command.Pattern.Mode,
Availability = command.Pattern.Availability,
ApprovalPolicy = new ApprovalPolicyDto(),
Agents = command.Pattern.Agents,
Rules =
[
new ApprovalCheckpointRuleDto
{
Kind = "tool-call",
AgentIds = ["agent-1"],
},
],
AutoApprovedToolNames = [category],
}),
};
}
private static RunTurnCommandDto CreateCommandWithConfiguredMcpServers(
IReadOnlyList<string> serverNames,
IReadOnlyList<string>? autoApprovedToolNames = null)
{
RunTurnCommandDto command = CreateCommandWithToolApproval();
return new RunTurnCommandDto
{
RequestId = command.RequestId,
SessionId = command.SessionId,
ProjectPath = command.ProjectPath,
Tooling = new RunTurnToolingConfigDto
{
McpServers = [.. serverNames.Select(CreateMcpServerConfig)],
},
Workflow = CreateWorkflow(new ApprovalPolicyDto
{
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,
},
};
}
private static RunTurnMcpServerConfigDto CreateMcpServerConfig(string serverName)
=> new()
{
Id = serverName,
Name = serverName,
};
private static HookCommandDefinition CreateHookCommand(string name)
=> new()
{
@@ -323,3 +461,4 @@ public sealed class CopilotSessionHooksTests
string InputJson,
string ProjectPath);
}
@@ -1,6 +1,7 @@
using Aryx.AgentHost.Contracts;
using Aryx.AgentHost.Services;
using GitHub.Copilot.SDK;
using Microsoft.Extensions.AI;
namespace Aryx.AgentHost.Tests;
@@ -13,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
@@ -36,7 +37,7 @@ public sealed class CopilotTurnExecutionStateTests
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Pattern.Agents[0],
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
@@ -59,28 +60,348 @@ public sealed class CopilotTurnExecutionStateTests
}
[Fact]
public void ObserveSessionEvent_ToolExecutionStart_TracksToolNameByCallId()
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()
{
RunTurnCommandDto command = CreateCommand();
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","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.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]
public void ObserveSessionEvent_ToolExecutionStart_DoesNotQueueToolActivityForHandoffTools()
{
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":"handoff_to_specialist"},"id":"1ce9d1dc-68f1-4df5-9728-f97017233279","timestamp":"2026-03-27T00:00:00Z"}"""));
Assert.Empty(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
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]
public void ObserveSessionEvent_AssistantMessageWithToolRequests_QueuesMessageReclassifiedEvent()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
"type": "assistant.message",
"data": {
"messageId": "msg-2",
"content": "Let me search for that.",
"toolRequests": [
{
"toolCallId": "tool-call-1",
"name": "rg",
"arguments": {
"pattern": "identifierUri"
}
}
]
},
"id": "3f75988b-8e69-4c90-a203-6b01d1c1f90b",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
IReadOnlyList<SidecarEventDto> pending = state.DrainPendingEvents();
AgentActivityEventDto thinking = Assert.Single(pending.OfType<AgentActivityEventDto>());
Assert.Equal("thinking", thinking.ActivityType);
MessageReclassifiedEventDto reclassified = Assert.Single(pending.OfType<MessageReclassifiedEventDto>());
Assert.Equal("session-1", reclassified.SessionId);
Assert.Equal("msg-2", reclassified.MessageId);
Assert.Equal("thinking", reclassified.NewKind);
}
[Fact]
public void ObserveSessionEvent_ToolExecutionStart_ReclassifiesLastObservedMessageOnce()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
"type": "assistant.message_delta",
"data": {
"messageId": "msg-3",
"deltaContent": "Searching"
},
"id": "0b65f0e9-d0fb-417e-ab5c-7a3343d8581b",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
_ = state.DrainPendingEvents();
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
"type": "tool.execution_start",
"data": {
"toolCallId": "tool-call-1",
"toolName": "rg"
},
"id": "8f33240e-bd3f-475c-aeb6-a4b7908e47b0",
"timestamp": "2026-03-27T00:00:01Z"
}
"""));
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
"type": "tool.execution_start",
"data": {
"toolCallId": "tool-call-2",
"toolName": "view"
},
"id": "33333333-3333-3333-3333-333333333333",
"id": "a23f9c9a-f947-4282-866d-f599451c3899",
"timestamp": "2026-03-27T00:00:02Z"
}
"""));
IReadOnlyList<SidecarEventDto> pending = state.DrainPendingEvents();
AgentActivityEventDto[] toolActivities = [.. pending.OfType<AgentActivityEventDto>().Where(activity => activity.ActivityType == "tool-calling")];
Assert.Equal(2, toolActivities.Length);
Assert.Contains(toolActivities, activity => activity.ToolCallId == "tool-call-1" && activity.ToolName == "rg");
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.ToolCalls.TryGetToolName("tool-call-1", out string? firstToolName));
Assert.Equal("rg", firstToolName);
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]
public void ObserveSessionEvent_AssistantMessageWithoutToolRequests_DoesNotQueueMessageReclassifiedEvent()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
"type": "assistant.message",
"data": {
"messageId": "msg-4",
"content": "Final answer."
},
"id": "d07fe954-1258-4f6a-bf79-1550d6143ed0",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? toolName));
Assert.Equal("view", toolName);
Assert.Empty(state.DrainPendingEvents().OfType<MessageReclassifiedEventDto>());
}
[Fact]
@@ -90,7 +411,7 @@ public sealed class CopilotTurnExecutionStateTests
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Pattern.Agents[0],
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
@@ -119,6 +440,160 @@ public sealed class CopilotTurnExecutionStateTests
Assert.Equal("agent-1", thinking.AgentId);
}
[Fact]
public void ObserveSessionEvent_AssistantIntent_QueuesIntentEvent()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
"type": "assistant.intent",
"data": {
"intent": "Searching incident playbooks"
},
"id": "64cf59fe-63f0-4217-adf4-9bd6b3a80452",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
IReadOnlyList<SidecarEventDto> pending = state.DrainPendingEvents();
AgentActivityEventDto thinking = Assert.Single(pending.OfType<AgentActivityEventDto>());
Assert.Equal("thinking", thinking.ActivityType);
AssistantIntentEventDto intent = Assert.Single(pending.OfType<AssistantIntentEventDto>());
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]
public void ObserveSessionEvent_AssistantReasoningDelta_QueuesReasoningDeltaEvent()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
"type": "assistant.reasoning_delta",
"data": {
"reasoningId": "reasoning-2",
"deltaContent": "Searching logs."
},
"id": "bd269258-5e5d-46b6-bf3f-bd8cba793b1a",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
IReadOnlyList<SidecarEventDto> pending = state.DrainPendingEvents();
AgentActivityEventDto thinking = Assert.Single(pending.OfType<AgentActivityEventDto>());
Assert.Equal("thinking", thinking.ActivityType);
ReasoningDeltaEventDto reasoning = Assert.Single(pending.OfType<ReasoningDeltaEventDto>());
Assert.Equal("session-1", reasoning.SessionId);
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]
public void DrainPendingMcpOauthRequests_ReturnsQueuedRequestsAndClearsQueue()
{
@@ -151,7 +626,7 @@ public sealed class CopilotTurnExecutionStateTests
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Pattern.Agents[0],
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
@@ -181,7 +656,7 @@ public sealed class CopilotTurnExecutionStateTests
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Pattern.Agents[0],
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
@@ -212,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);
@@ -227,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);
@@ -245,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());
}
@@ -258,7 +733,7 @@ public sealed class CopilotTurnExecutionStateTests
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Pattern.Agents[0],
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
@@ -321,7 +796,7 @@ public sealed class CopilotTurnExecutionStateTests
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Pattern.Agents[0],
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
@@ -356,7 +831,7 @@ public sealed class CopilotTurnExecutionStateTests
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Pattern.Agents[0],
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
@@ -372,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(
@@ -411,29 +933,166 @@ public sealed class CopilotTurnExecutionStateTests
""");
}
[Fact]
public void FinalizeCompletedMessages_TagsReclassifiedMessagesAsThinking()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
// Simulate assistant message with tool requests → triggers reclassification
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
"type": "assistant.message",
"data": {
"messageId": "msg-intermediate",
"content": "Let me search...",
"toolRequests": [
{
"toolCallId": "tool-call-1",
"name": "grep",
"arguments": {}
}
]
},
"id": "11111111-1111-1111-1111-111111111111",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
state.DrainPendingEvents();
// Build completed messages with a reclassified and a non-reclassified message
ChatMessage intermediateMsg = new(ChatRole.Assistant, "Let me search...");
intermediateMsg.MessageId = "msg-intermediate";
intermediateMsg.AuthorName = "Primary";
ChatMessage finalMsg = new(ChatRole.Assistant, "Here are the results.");
finalMsg.MessageId = "msg-final";
finalMsg.AuthorName = "Primary";
state.UpdateCompletedMessages([intermediateMsg, finalMsg], []);
IReadOnlyList<ChatMessageDto> messages = state.FinalizeCompletedMessages();
ChatMessageDto intermediate = Assert.Single(messages, m => m.Id == "msg-intermediate");
Assert.Equal("thinking", intermediate.MessageKind);
ChatMessageDto final_ = Assert.Single(messages, m => m.Id == "msg-final");
Assert.Null(final_.MessageKind);
}
private static RunTurnCommandDto CreateCommand()
{
return new RunTurnCommandDto
{
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
@@ -11,21 +11,32 @@ public sealed class HandoffWorkflowGuidanceTests
string instructions = HandoffWorkflowGuidance.CreateWorkflowInstructions();
Assert.Contains("explicit handoffs", instructions, StringComparison.OrdinalIgnoreCase);
Assert.Contains("routing or triage agent", instructions, StringComparison.OrdinalIgnoreCase);
Assert.Contains("best specialist", instructions, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Do not inspect files", instructions, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Do not claim that you delegated", instructions, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Do not narrate a handoff", instructions, StringComparison.OrdinalIgnoreCase);
Assert.Contains("own the substantive answer", instructions, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Specialists should complete the substantive work", instructions, StringComparison.OrdinalIgnoreCase);
}
[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);
@@ -38,13 +49,20 @@ public sealed class HandoffWorkflowGuidanceTests
[Fact]
public void CreateReturnReason_RestrictsReturnToReroutingCases()
{
PatternAgentDefinitionDto triage = new()
WorkflowNodeDto triage = new()
{
Id = "agent-handoff-triage",
Name = "Triage",
Description = "Routes the request to the right specialist.",
Instructions = "Triages requests.",
Model = "gpt-5.4",
Kind = "agent",
Label = "Triage",
Config = new WorkflowNodeConfigDto
{
Kind = "agent",
Id = "agent-handoff-triage",
Name = "Triage",
Description = "Routes the request to the right specialist.",
Instructions = "Triages requests.",
Model = "gpt-5.4",
},
};
string reason = HandoffWorkflowGuidance.CreateReturnReason(triage);
@@ -1,127 +0,0 @@
using Aryx.AgentHost.Contracts;
using Aryx.AgentHost.Services;
namespace Aryx.AgentHost.Tests;
public sealed class PatternGraphResolverTests
{
[Fact]
public void ResolveOrderedAgentIds_UsesSequentialGraphPath()
{
PatternDefinitionDto pattern = CreatePattern(
"sequential",
[
CreateAgent("agent-1", "Analyst"),
CreateAgent("agent-2", "Builder"),
CreateAgent("agent-3", "Reviewer"),
],
new PatternGraphDto
{
Nodes =
[
CreateSystemNode("system-user-input", "user-input"),
CreateAgentNode("agent-1", 0),
CreateAgentNode("agent-2", 1),
CreateAgentNode("agent-3", 2),
CreateSystemNode("system-user-output", "user-output"),
],
Edges =
[
CreateEdge("system-user-input", "agent-node-agent-3"),
CreateEdge("agent-node-agent-3", "agent-node-agent-1"),
CreateEdge("agent-node-agent-1", "agent-node-agent-2"),
CreateEdge("agent-node-agent-2", "system-user-output"),
],
});
IReadOnlyList<string> orderedAgentIds = PatternGraphResolver.ResolveOrderedAgentIds(pattern);
Assert.Equal(["agent-3", "agent-1", "agent-2"], orderedAgentIds);
}
[Fact]
public void ResolveHandoff_UsesExplicitEntryAndRoutes()
{
PatternDefinitionDto pattern = CreatePattern(
"handoff",
[
CreateAgent("agent-1", "Triage"),
CreateAgent("agent-2", "UX"),
CreateAgent("agent-3", "Runtime"),
],
new PatternGraphDto
{
Nodes =
[
CreateSystemNode("system-user-input", "user-input"),
CreateSystemNode("system-user-output", "user-output"),
CreateAgentNode("agent-1", 0),
CreateAgentNode("agent-2", 1),
CreateAgentNode("agent-3", 2),
],
Edges =
[
CreateEdge("system-user-input", "agent-node-agent-3"),
CreateEdge("agent-node-agent-3", "agent-node-agent-2"),
CreateEdge("agent-node-agent-2", "agent-node-agent-1"),
CreateEdge("agent-node-agent-2", "system-user-output"),
],
});
PatternHandoffTopology topology = PatternGraphResolver.ResolveHandoff(pattern);
Assert.Equal("agent-3", topology.EntryAgentId);
Assert.Contains(new PatternHandoffRoute("agent-3", "agent-2"), topology.Routes);
Assert.Contains(new PatternHandoffRoute("agent-2", "agent-1"), topology.Routes);
Assert.DoesNotContain(new PatternHandoffRoute("agent-1", "agent-2"), topology.Routes);
}
private static PatternDefinitionDto CreatePattern(
string mode,
IReadOnlyList<PatternAgentDefinitionDto> agents,
PatternGraphDto graph)
=> new()
{
Id = $"{mode}-pattern",
Name = "Pattern",
Mode = mode,
Availability = "available",
Agents = agents,
Graph = graph,
};
private static PatternAgentDefinitionDto CreateAgent(string id, string name)
=> new()
{
Id = id,
Name = name,
Model = "gpt-5.4",
Instructions = "Help with the user's request.",
};
private static PatternGraphNodeDto CreateSystemNode(string id, string kind)
=> new()
{
Id = id,
Kind = kind,
Position = new PatternGraphPositionDto(),
};
private static PatternGraphNodeDto CreateAgentNode(string agentId, int order)
=> new()
{
Id = $"agent-node-{agentId}",
Kind = "agent",
AgentId = agentId,
Order = order,
Position = new PatternGraphPositionDto(),
};
private static PatternGraphEdgeDto CreateEdge(string source, string target)
=> new()
{
Id = $"edge-{source}-to-{target}",
Source = source,
Target = target,
};
}
@@ -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,
@@ -232,12 +323,65 @@ public sealed class SidecarProtocolHostTests
});
}
[Fact]
public async Task RunTurnCommand_ReturnsWorkflowDiagnosticEventsAndCompletion()
{
SidecarProtocolHost host = new(
new WorkflowValidator(),
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
{
await onActivity(new WorkflowDiagnosticEventDto
{
Type = "workflow-diagnostic",
RequestId = command.RequestId,
SessionId = command.SessionId,
Severity = "error",
DiagnosticKind = "executor-failed",
Message = "Tool crashed.",
AgentId = "agent-1",
AgentName = "Primary",
ExecutorId = "agent-1",
ExceptionType = "InvalidOperationException",
});
return [];
}));
IReadOnlyList<JsonElement> events = await RunHostAsync(
CreateRunTurnCommand(requestId: "turn-diagnostic", messages: []),
host);
Assert.Collection(
events,
diagnosticEvent =>
{
Assert.Equal("workflow-diagnostic", diagnosticEvent.GetProperty("type").GetString());
Assert.Equal("turn-diagnostic", diagnosticEvent.GetProperty("requestId").GetString());
Assert.Equal("session-1", diagnosticEvent.GetProperty("sessionId").GetString());
Assert.Equal("error", diagnosticEvent.GetProperty("severity").GetString());
Assert.Equal("executor-failed", diagnosticEvent.GetProperty("diagnosticKind").GetString());
Assert.Equal("Tool crashed.", diagnosticEvent.GetProperty("message").GetString());
Assert.Equal("agent-1", diagnosticEvent.GetProperty("executorId").GetString());
},
completionEvent =>
{
Assert.Equal("turn-complete", completionEvent.GetProperty("type").GetString());
Assert.Equal("session-1", completionEvent.GetProperty("sessionId").GetString());
Assert.False(completionEvent.GetProperty("cancelled").GetBoolean());
},
commandCompleteEvent =>
{
Assert.Equal("command-complete", commandCompleteEvent.GetProperty("type").GetString());
Assert.Equal("turn-diagnostic", commandCompleteEvent.GetProperty("requestId").GetString());
});
}
[Fact]
public async Task RunTurnCommand_DeserializesInteractionMode()
{
string? capturedMode = null;
SidecarProtocolHost host = new(
new PatternValidator(),
new WorkflowValidator(),
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
{
capturedMode = command.Mode;
@@ -245,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);
@@ -273,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
@@ -300,24 +426,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(
@@ -351,7 +460,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
@@ -371,24 +480,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(
@@ -424,7 +516,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
@@ -482,7 +574,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
@@ -503,25 +595,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(
@@ -557,7 +631,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);
@@ -605,7 +679,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);
@@ -627,7 +701,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) =>
@@ -660,7 +734,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) =>
@@ -691,7 +765,7 @@ public sealed class SidecarProtocolHostTests
[Fact]
public void MapRuntimeTools_ExcludesOnlyInternalMetaToolsAndDeduplicatesByName()
{
IReadOnlyList<SidecarRuntimeToolDto> runtimeTools = SidecarProtocolHost.MapRuntimeTools(
IReadOnlyList<SidecarRuntimeToolDto> runtimeTools = CopilotAgentProvider.MapRuntimeTools(
[
new Tool
{
@@ -744,7 +818,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);
@@ -754,7 +828,7 @@ public sealed class SidecarProtocolHostTests
public void CreateReadyConnectionDiagnostics_ReportsCliPathAndModelCount()
{
SidecarConnectionDiagnosticsDto diagnostics =
SidecarProtocolHost.CreateReadyConnectionDiagnostics(
CopilotAgentProvider.CreateReadyConnectionDiagnostics(
@"C:\tools\copilot\copilot.exe",
2,
new SidecarCopilotCliVersionDiagnosticsDto
@@ -784,7 +858,7 @@ public sealed class SidecarProtocolHostTests
public async Task ListSessionsCommand_ReturnsSessionsListedEvent()
{
SidecarProtocolHost host = new(
new PatternValidator(),
new WorkflowValidator(),
sessionManager: new FakeSessionManager
{
Sessions =
@@ -831,7 +905,7 @@ public sealed class SidecarProtocolHostTests
],
};
SidecarProtocolHost host = new(
new PatternValidator(),
new WorkflowValidator(),
sessionManager: sessionManager);
IReadOnlyList<JsonElement> events = await RunHostAsync(
@@ -854,7 +928,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)
@@ -896,7 +970,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(
[
@@ -957,7 +1031,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)
@@ -1035,24 +1109,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
{
@@ -1060,18 +1145,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
{
@@ -1085,6 +1161,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<
@@ -1147,6 +1242,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; } = [];
@@ -1184,3 +1311,4 @@ public sealed class SidecarProtocolHostTests
}
}
}
@@ -1,169 +0,0 @@
using Aryx.AgentHost.Contracts;
using Aryx.AgentHost.Services;
namespace Aryx.AgentHost.Tests;
public sealed class PatternValidatorTests
{
private readonly PatternValidator _validator = new();
[Fact]
public void SingleAgentPattern_WithExactlyOneAgent_IsValid()
{
IReadOnlyList<PatternValidationIssueDto> issues = _validator.Validate(
CreatePattern(
"single",
[CreateAgent()]));
Assert.Empty(issues);
}
[Fact]
public void HandoffPattern_WithSingleAgent_IsReportedAsInvalid()
{
IReadOnlyList<PatternValidationIssueDto> issues = _validator.Validate(
CreatePattern(
"handoff",
[CreateAgent()]));
Assert.Contains(issues, issue =>
issue.Field == "agents"
&& issue.Message == "Handoff orchestration requires at least two agents.");
}
[Fact]
public void AgentWithoutModel_IsReportedAsInvalid()
{
IReadOnlyList<PatternValidationIssueDto> issues = _validator.Validate(
CreatePattern(
"sequential",
[
CreateAgent(model: ""),
CreateAgent(id: "agent-2", name: "Reviewer"),
]));
Assert.Contains(issues, issue =>
issue.Field == "agents.model"
&& issue.Message == "Agent \"Primary\" requires a model identifier.");
}
[Fact]
public void MagenticPattern_IsReportedAsUnavailable()
{
IReadOnlyList<PatternValidationIssueDto> issues = _validator.Validate(
CreatePattern(
"magentic",
[
CreateAgent(id: "agent-1", name: "Planner", instructions: "Plan the task."),
CreateAgent(
id: "agent-2",
name: "Specialist",
model: "claude-opus-4.5",
instructions: "Complete the task."),
],
availability: "unavailable",
unavailabilityReason: "Unsupported in C#.",
name: "Magentic"));
Assert.Contains(issues, issue =>
issue.Field == "availability"
&& issue.Message.Contains("Unsupported", StringComparison.OrdinalIgnoreCase));
Assert.Contains(issues, issue =>
issue.Field == "mode"
&& issue.Message.Contains("Unsupported", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void SequentialPattern_WithBranchedGraph_IsReportedAsInvalid()
{
IReadOnlyList<PatternValidationIssueDto> issues = _validator.Validate(
CreatePattern(
"sequential",
[
CreateAgent(id: "agent-1", name: "Analyst"),
CreateAgent(id: "agent-2", name: "Builder"),
],
graph: new PatternGraphDto
{
Nodes =
[
CreateSystemNode("system-user-input", "user-input"),
CreateAgentNode("agent-1", 0),
CreateAgentNode("agent-2", 1),
CreateSystemNode("system-user-output", "user-output"),
],
Edges =
[
CreateEdge("system-user-input", "agent-node-agent-1"),
CreateEdge("system-user-input", "agent-node-agent-2"),
CreateEdge("agent-node-agent-1", "agent-node-agent-2"),
CreateEdge("agent-node-agent-2", "system-user-output"),
],
}));
Assert.Contains(issues, issue =>
issue.Field == "graph"
&& issue.Message.Contains("single path", StringComparison.OrdinalIgnoreCase));
}
private static PatternDefinitionDto CreatePattern(
string mode,
IReadOnlyList<PatternAgentDefinitionDto> agents,
string availability = "available",
string? unavailabilityReason = null,
string name = "Pattern",
PatternGraphDto? graph = null)
{
return new PatternDefinitionDto
{
Id = $"{mode}-pattern",
Name = name,
Mode = mode,
Availability = availability,
UnavailabilityReason = unavailabilityReason,
Agents = agents,
Graph = graph,
};
}
private static PatternAgentDefinitionDto CreateAgent(
string id = "agent-1",
string name = "Primary",
string model = "gpt-5.4",
string instructions = "Help with the user's request.")
{
return new PatternAgentDefinitionDto
{
Id = id,
Name = name,
Model = model,
Instructions = instructions,
};
}
private static PatternGraphNodeDto CreateSystemNode(string id, string kind)
=> new()
{
Id = id,
Kind = kind,
Position = new PatternGraphPositionDto(),
};
private static PatternGraphNodeDto CreateAgentNode(string agentId, int order)
=> new()
{
Id = $"agent-node-{agentId}",
Kind = "agent",
AgentId = agentId,
Order = order,
Position = new PatternGraphPositionDto(),
};
private static PatternGraphEdgeDto CreateEdge(string source, string target)
=> new()
{
Id = $"edge-{source}-to-{target}",
Source = source,
Target = target,
};
}
@@ -0,0 +1,80 @@
using Aryx.AgentHost.Contracts;
using Aryx.AgentHost.Services;
namespace Aryx.AgentHost.Tests;
public sealed class WorkflowConditionEvaluatorTests
{
[Fact]
public void Evaluate_PropertyCondition_MatchesPayload()
{
EdgeConditionDto condition = new()
{
Type = "property",
Combinator = "and",
Rules =
[
new WorkflowConditionRuleDto
{
PropertyPath = "Role",
Operator = "equals",
Value = "user",
},
],
};
bool matched = WorkflowConditionEvaluator.Evaluate(condition, new TestPayload("user", 1, "hello"));
Assert.True(matched);
}
[Fact]
public void Evaluate_ExpressionCondition_MatchesPayload()
{
EdgeConditionDto condition = new()
{
Type = "expression",
Expression = "Iteration < 3 && Role == \"user\"",
};
bool matched = WorkflowConditionEvaluator.Evaluate(condition, new TestPayload("user", 2, "hello"));
Assert.True(matched);
}
[Fact]
public void Compile_LoopCondition_StopsAfterMaxIterations()
{
WorkflowEdgeDto edge = new()
{
Id = "edge-loop",
Source = "agent",
Target = "agent",
Kind = "direct",
IsLoop = true,
MaxIterations = 2,
Condition = new EdgeConditionDto
{
Type = "property",
Rules =
[
new WorkflowConditionRuleDto
{
PropertyPath = "Iteration",
Operator = "lt",
Value = "10",
},
],
},
};
Func<object?, bool>? compiled = WorkflowConditionEvaluator.Compile(edge);
Assert.NotNull(compiled);
Assert.True(compiled!(new TestPayload("user", 1, "hello")));
Assert.True(compiled(new TestPayload("user", 2, "hello")));
Assert.False(compiled(new TestPayload("user", 3, "hello")));
}
private sealed record TestPayload(string Role, int Iteration, string Content);
}
@@ -1,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,83 +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_LeavesToolArgumentsNullWhenFunctionCallHasNoUsableArguments()
{
var tracking = CreateToolTracking();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
{
["empty"] = " ",
["missing"] = null,
}));
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
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.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"));
@@ -97,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);
@@ -106,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]
@@ -165,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,
},
};
}
@@ -224,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()
@@ -247,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(),
},
};
}
}
+993 -1033
View File
File diff suppressed because it is too large Load Diff
+141
View File
@@ -0,0 +1,141 @@
import { basename } from 'node:path';
import type {
ProjectGitCommitMessageSuggestion,
ProjectGitConventionalCommitType,
ProjectGitRunChangeSummary,
} from '@shared/domain/project';
import type { SessionRecord } from '@shared/domain/session';
import type { SessionRunRecord } from '@shared/domain/runTimeline';
interface BuildCommitMessageSuggestionInput {
session: Pick<SessionRecord, 'messages' | 'title'>;
run: Pick<SessionRunRecord, 'triggerMessageId'>;
summary?: ProjectGitRunChangeSummary;
conventionalType?: ProjectGitConventionalCommitType;
}
const COMMIT_TYPES: readonly ProjectGitConventionalCommitType[] = [
'feat',
'fix',
'refactor',
'docs',
'test',
'chore',
];
function normalizeWhitespace(value: string): string {
return value.replace(/\s+/g, ' ').trim();
}
function isCommitType(value: string | undefined): value is ProjectGitConventionalCommitType {
return value !== undefined && COMMIT_TYPES.includes(value as ProjectGitConventionalCommitType);
}
function findTriggerMessageContent(
session: Pick<SessionRecord, 'messages'>,
triggerMessageId: string,
): string | undefined {
return session.messages.find((message) => message.id === triggerMessageId)?.content;
}
function inferCommitTypeFromSummary(
prompt: string | undefined,
summary: ProjectGitRunChangeSummary | undefined,
): ProjectGitConventionalCommitType {
const promptText = normalizeWhitespace(prompt?.toLowerCase() ?? '');
const files = summary?.files ?? [];
const filePaths = files.map((file) => file.path.toLowerCase());
if (filePaths.length > 0 && filePaths.every((path) => path.endsWith('.md') || path.includes('readme'))) {
return 'docs';
}
if (filePaths.length > 0 && filePaths.every((path) => path.includes('test') || path.endsWith('.snap'))) {
return 'test';
}
if (/\b(fix|bug|error|issue|regression|broken|failure)\b/.test(promptText)) {
return 'fix';
}
if (/\b(refactor|cleanup|restructure|rename|simplify)\b/.test(promptText)) {
return 'refactor';
}
if (/\b(doc|readme|documentation)\b/.test(promptText)) {
return 'docs';
}
if (/\b(test|coverage|assertion)\b/.test(promptText)) {
return 'test';
}
if (/\b(chore|config|build|deps|dependency|tooling)\b/.test(promptText)) {
return 'chore';
}
return 'feat';
}
function stripPromptLead(text: string): string {
return text
.replace(/^[`"'“”‘’]+|[`"'“”‘’]+$/g, '')
.replace(/^(please\s+)?(can|could|would)\s+you\s+/i, '')
.replace(/^(implement|add|create|build|make|update|improve|refactor|fix|support|handle)\s+/i, '')
.replace(/[.?!:;]+$/g, '')
.trim();
}
function summarizeFiles(summary: ProjectGitRunChangeSummary | undefined): string | undefined {
const firstFile = summary?.files[0];
if (!summary || summary.files.length === 0 || !firstFile) {
return undefined;
}
if (summary.files.length === 1) {
return basename(firstFile.path).replace(/\.[^.]+$/, '');
}
return `${summary.fileCount} files`;
}
function buildSubject(
prompt: string | undefined,
summary: ProjectGitRunChangeSummary | undefined,
): string {
const normalizedPrompt = normalizeWhitespace(prompt ?? '');
if (normalizedPrompt) {
const firstSentence = normalizedPrompt.split(/[\r\n.?!]/, 1)[0] ?? normalizedPrompt;
const stripped = stripPromptLead(firstSentence);
if (stripped) {
return stripped
.replace(/\b(the|a|an)\s+/gi, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
}
}
const fallback = summarizeFiles(summary);
if (fallback) {
return `update ${fallback}`.toLowerCase();
}
return 'update project changes';
}
export function buildProjectGitCommitMessageSuggestion(
input: BuildCommitMessageSuggestionInput,
): ProjectGitCommitMessageSuggestion {
const prompt = findTriggerMessageContent(input.session, input.run.triggerMessageId);
const type = isCommitType(input.conventionalType)
? input.conventionalType
: inferCommitTypeFromSummary(prompt, input.summary);
const subject = buildSubject(prompt, input.summary);
return {
type,
subject,
message: `${type}: ${subject}`,
};
}
+327
View File
@@ -0,0 +1,327 @@
import { Buffer } from 'node:buffer';
import { isUtf8 } from 'node:buffer';
import type {
ProjectGitBaselineFile,
ProjectGitDiffPreview,
ProjectGitRunChangeCounts,
ProjectGitRunChangeKind,
ProjectGitRunChangeSummary,
ProjectGitRunChangedFile,
ProjectGitWorkingTreeFile,
ProjectGitWorkingTreeSnapshot,
} from '@shared/domain/project';
interface DiffStats {
additions: number;
deletions: number;
}
interface BuildProjectGitRunChangeSummaryInput {
generatedAt: string;
preRunSnapshot?: ProjectGitWorkingTreeSnapshot;
preRunBaselineFiles?: readonly ProjectGitBaselineFile[];
postRunSnapshot?: ProjectGitWorkingTreeSnapshot;
postRunBaselineFiles?: readonly ProjectGitBaselineFile[];
}
function parseDiffStats(diff: string | undefined): DiffStats {
if (!diff) {
return { additions: 0, deletions: 0 };
}
let additions = 0;
let deletions = 0;
for (const line of diff.split('\n')) {
if (line.startsWith('+') && !line.startsWith('+++')) {
additions += 1;
} else if (line.startsWith('-') && !line.startsWith('---')) {
deletions += 1;
}
}
return { additions, deletions };
}
function isBinaryDiff(diff: string | undefined): boolean {
if (!diff) {
return false;
}
return diff.includes('GIT binary patch') || diff.includes('Binary files ');
}
function decodeUtf8FromBase64(value: string | undefined): string | undefined {
if (value === undefined) {
return undefined;
}
const buffer = Buffer.from(value, 'base64');
return isUtf8(buffer) ? buffer.toString('utf8') : undefined;
}
function canRestoreBaseline(
baseline: ProjectGitBaselineFile | undefined,
): baseline is ProjectGitBaselineFile {
return baseline !== undefined
&& (baseline.untrackedContentBase64 !== undefined || baseline.combinedDiff !== undefined);
}
function previewFromBaselineFile(
file: Pick<ProjectGitWorkingTreeFile, 'path'>,
baseline: ProjectGitBaselineFile | undefined,
): ProjectGitDiffPreview | undefined {
if (!baseline) {
return undefined;
}
if (baseline.untrackedContentBase64 !== undefined) {
return {
path: file.path,
previousPath: baseline.previousPath,
newFileContents: decodeUtf8FromBase64(baseline.untrackedContentBase64),
...(baseline.isBinary ? { isBinary: true } : {}),
};
}
if (baseline.combinedDiff === undefined && baseline.isBinary !== true) {
return undefined;
}
return {
path: file.path,
previousPath: baseline.previousPath,
...(baseline.combinedDiff && !isBinaryDiff(baseline.combinedDiff)
? { diff: baseline.combinedDiff }
: {}),
...(baseline.isBinary || isBinaryDiff(baseline.combinedDiff) ? { isBinary: true } : {}),
};
}
function sameWorkingTreeFile(
left: ProjectGitWorkingTreeFile,
right: ProjectGitWorkingTreeFile,
): boolean {
return (
left.path === right.path
&& left.previousPath === right.previousPath
&& left.stagedStatus === right.stagedStatus
&& left.unstagedStatus === right.unstagedStatus
&& left.isConflicted === right.isConflicted
);
}
function sameBaselineFile(
left: ProjectGitBaselineFile | undefined,
right: ProjectGitBaselineFile | undefined,
): boolean {
if (!left && !right) {
return true;
}
if (!left || !right) {
return false;
}
return (
left.path === right.path
&& left.previousPath === right.previousPath
&& left.combinedDiff === right.combinedDiff
&& left.untrackedContentBase64 === right.untrackedContentBase64
&& left.isBinary === right.isBinary
);
}
function createRunChangeCounts(): ProjectGitRunChangeCounts {
return {
added: 0,
modified: 0,
deleted: 0,
renamed: 0,
copied: 0,
typeChanged: 0,
unmerged: 0,
untracked: 0,
cleaned: 0,
};
}
function incrementRunChangeCount(
counts: ProjectGitRunChangeCounts,
kind: ProjectGitRunChangeKind,
): void {
switch (kind) {
case 'added':
counts.added += 1;
break;
case 'modified':
counts.modified += 1;
break;
case 'deleted':
counts.deleted += 1;
break;
case 'renamed':
counts.renamed += 1;
break;
case 'copied':
counts.copied += 1;
break;
case 'type-changed':
counts.typeChanged += 1;
break;
case 'unmerged':
counts.unmerged += 1;
break;
case 'untracked':
counts.untracked += 1;
break;
case 'cleaned':
counts.cleaned += 1;
break;
}
}
function resolveRunChangeKind(file: ProjectGitWorkingTreeFile): ProjectGitRunChangeKind {
if (file.isConflicted) {
return 'unmerged';
}
return file.unstagedStatus ?? file.stagedStatus ?? 'modified';
}
function sortRunChangedFiles(
left: ProjectGitRunChangedFile,
right: ProjectGitRunChangedFile,
): number {
if (left.origin !== right.origin) {
return left.origin === 'run-created' ? -1 : 1;
}
return left.path.localeCompare(right.path);
}
export function buildProjectGitRunChangeSummary(
input: BuildProjectGitRunChangeSummaryInput,
): ProjectGitRunChangeSummary | undefined {
const {
generatedAt,
preRunSnapshot,
preRunBaselineFiles,
postRunSnapshot,
postRunBaselineFiles,
} = input;
if (!preRunSnapshot || !postRunSnapshot) {
return undefined;
}
const preRunFilesByPath = new Map(
preRunSnapshot.files.map((file) => [file.path, file] satisfies [string, ProjectGitWorkingTreeFile]),
);
const preRunBaselineByPath = new Map(
(preRunBaselineFiles ?? []).map((file) => [file.path, file] satisfies [string, ProjectGitBaselineFile]),
);
const postRunBaselineByPath = new Map(
(postRunBaselineFiles ?? []).map((file) => [file.path, file] satisfies [string, ProjectGitBaselineFile]),
);
const matchedPreRunPaths = new Set<string>();
const files: ProjectGitRunChangedFile[] = [];
for (const postRunFile of postRunSnapshot.files) {
const matchedPreRunFile = preRunFilesByPath.get(postRunFile.path)
?? (postRunFile.previousPath ? preRunFilesByPath.get(postRunFile.previousPath) : undefined);
const matchedPreRunPath = matchedPreRunFile?.path;
const postRunBaseline = postRunBaselineByPath.get(postRunFile.path);
if (!matchedPreRunFile || !matchedPreRunPath) {
const preview = previewFromBaselineFile(postRunFile, postRunBaseline);
const stats = parseDiffStats(preview?.diff);
files.push({
path: postRunFile.path,
previousPath: postRunFile.previousPath,
kind: resolveRunChangeKind(postRunFile),
origin: 'run-created',
stagedStatus: postRunFile.stagedStatus,
unstagedStatus: postRunFile.unstagedStatus,
...(postRunFile.isConflicted ? { isConflicted: true } : {}),
additions: stats.additions,
deletions: stats.deletions,
canRevert: true,
...(preview ? { preview } : {}),
});
continue;
}
matchedPreRunPaths.add(matchedPreRunPath);
const preRunBaseline = preRunBaselineByPath.get(matchedPreRunPath);
if (sameWorkingTreeFile(matchedPreRunFile, postRunFile) && sameBaselineFile(preRunBaseline, postRunBaseline)) {
continue;
}
const preview = previewFromBaselineFile(postRunFile, postRunBaseline);
const stats = parseDiffStats(preview?.diff);
files.push({
path: postRunFile.path,
previousPath: postRunFile.previousPath,
kind: resolveRunChangeKind(postRunFile),
origin: 'pre-existing',
stagedStatus: postRunFile.stagedStatus,
unstagedStatus: postRunFile.unstagedStatus,
...(postRunFile.isConflicted ? { isConflicted: true } : {}),
additions: stats.additions,
deletions: stats.deletions,
canRevert: canRestoreBaseline(preRunBaseline),
...(preview ? { preview } : {}),
});
}
for (const preRunFile of preRunSnapshot.files) {
if (matchedPreRunPaths.has(preRunFile.path)) {
continue;
}
const preRunBaseline = preRunBaselineByPath.get(preRunFile.path);
const preview = previewFromBaselineFile(preRunFile, preRunBaseline);
const stats = parseDiffStats(preview?.diff);
files.push({
path: preRunFile.path,
previousPath: preRunFile.previousPath,
kind: 'cleaned',
origin: 'pre-existing',
stagedStatus: preRunFile.stagedStatus,
unstagedStatus: preRunFile.unstagedStatus,
...(preRunFile.isConflicted ? { isConflicted: true } : {}),
additions: stats.additions,
deletions: stats.deletions,
canRevert: canRestoreBaseline(preRunBaseline),
...(preview ? { preview } : {}),
});
}
const branchChanged = preRunSnapshot.branch !== postRunSnapshot.branch;
if (files.length === 0 && !branchChanged) {
return undefined;
}
files.sort(sortRunChangedFiles);
const counts = createRunChangeCounts();
let additions = 0;
let deletions = 0;
for (const file of files) {
incrementRunChangeCount(counts, file.kind);
additions += file.additions;
deletions += file.deletions;
}
return {
generatedAt,
branchAtStart: preRunSnapshot.branch,
branchAtEnd: postRunSnapshot.branch,
...(branchChanged ? { branchChanged: true } : {}),
fileCount: files.length,
additions,
deletions,
counts,
files,
};
}
+614 -7
View File
@@ -1,9 +1,30 @@
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, isAbsolute, join, relative, resolve } from 'node:path';
import { promisify } from 'node:util';
import type { ProjectGitChangeSummary, ProjectGitCommitSummary, ProjectGitContext } from '@shared/domain/project';
import type {
ProjectGitBaselineFile,
ProjectGitBranchSummary,
ProjectGitChangeSummary,
ProjectGitCommitLogEntry,
ProjectGitCommitSummary,
ProjectGitContext,
ProjectGitDetails,
ProjectGitDiffPreview,
ProjectGitFileReference,
ProjectGitRunChangeSummary,
ProjectGitRunChangedFile,
ProjectGitWorkingTreeFile,
ProjectGitWorkingTreeFileStatus,
ProjectGitWorkingTreeSnapshot,
} from '@shared/domain/project';
import { nowIso } from '@shared/utils/ids';
import { buildProjectGitRunChangeSummary } from '@main/git/gitRunChangeSummary';
type ExecFileException = import('node:child_process').ExecFileException;
const require = createRequire(import.meta.url);
@@ -11,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 =
@@ -73,6 +104,11 @@ function isNotRepository(error: GitCommandFailure): boolean {
return detail.includes('not a git repository');
}
function isUnknownPathspec(error: GitCommandFailure): boolean {
const detail = `${error.message}\n${error.stderr ?? ''}`.toLowerCase();
return detail.includes('did not match any file') || detail.includes('pathspec');
}
function summarizeGitFailure(error: GitCommandFailure): string {
return error.stderr?.trim() || error.message;
}
@@ -102,9 +138,46 @@ function isConflictedStatus(x: string, y: string): boolean {
);
}
function parseChangeSummary(stdout: string): {
function parseWorkingTreeFileStatus(value: string): ProjectGitWorkingTreeFileStatus | undefined {
switch (value) {
case 'A':
return 'added';
case 'M':
return 'modified';
case 'D':
return 'deleted';
case 'R':
return 'renamed';
case 'C':
return 'copied';
case 'T':
return 'type-changed';
case 'U':
return 'unmerged';
case '?':
return 'untracked';
default:
return undefined;
}
}
function parseWorkingTreePath(rawPath: string): Pick<ProjectGitWorkingTreeFile, 'path' | 'previousPath'> {
const separator = ' -> ';
const separatorIndex = rawPath.indexOf(separator);
if (separatorIndex < 0) {
return { path: rawPath };
}
return {
previousPath: rawPath.slice(0, separatorIndex).trim(),
path: rawPath.slice(separatorIndex + separator.length).trim(),
};
}
function parseWorkingTree(stdout: string): {
changedFileCount: number;
changes: ProjectGitChangeSummary;
files: ProjectGitWorkingTreeFile[];
} {
const summary: ProjectGitChangeSummary = {
staged: 0,
@@ -112,6 +185,7 @@ function parseChangeSummary(stdout: string): {
untracked: 0,
conflicted: 0,
};
const files: ProjectGitWorkingTreeFile[] = [];
const lines = stdout
.split(/\r?\n/)
@@ -122,20 +196,42 @@ function parseChangeSummary(stdout: string): {
for (const line of lines) {
if (line.startsWith('??')) {
const path = line.slice(3).trim();
if (!path) {
continue;
}
summary.untracked += 1;
changedFileCount += 1;
files.push({
path,
unstagedStatus: 'untracked',
});
continue;
}
if (line.length < 2) {
if (line.length < 3) {
continue;
}
const x = line[0];
const y = line[1];
changedFileCount += 1;
const rawPath = line.slice(3).trim();
if (!rawPath) {
continue;
}
if (isConflictedStatus(x, y)) {
changedFileCount += 1;
const isConflicted = isConflictedStatus(x, y);
const pathInfo = parseWorkingTreePath(rawPath);
files.push({
...pathInfo,
stagedStatus: parseWorkingTreeFileStatus(x),
unstagedStatus: parseWorkingTreeFileStatus(y),
...(isConflicted ? { isConflicted: true } : {}),
});
if (isConflicted) {
summary.conflicted += 1;
continue;
}
@@ -152,6 +248,7 @@ function parseChangeSummary(stdout: string): {
return {
changedFileCount,
changes: summary,
files,
};
}
@@ -173,6 +270,122 @@ function parseHead(stdout: string): ProjectGitCommitSummary | undefined {
};
}
function parseBranchList(stdout: string): ProjectGitBranchSummary[] {
return stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.flatMap((line) => {
const [name, currentMarker, upstream] = line.split('\0');
const trimmedName = name?.trim();
if (!trimmedName) {
return [];
}
return [{
name: trimmedName,
isCurrent: currentMarker?.trim() === '*',
upstream: upstream?.trim() || undefined,
}];
});
}
function parseCommitLog(stdout: string): ProjectGitCommitLogEntry[] {
return stdout
.split('\x1e')
.map((record) => record.trim())
.filter(Boolean)
.flatMap((record) => {
const [hash, shortHash, authorName, subject, committedAt, refNames] = record.split('\0');
if (!hash || !shortHash || !authorName || !subject || !committedAt) {
return [];
}
return [{
hash: hash.trim(),
shortHash: shortHash.trim(),
authorName: authorName.trim(),
subject: subject.trim(),
committedAt: committedAt.trim(),
refNames: refNames?.trim() || undefined,
}];
});
}
function isPureUntrackedFile(file: ProjectGitWorkingTreeFile): boolean {
return file.stagedStatus === undefined && file.unstagedStatus === 'untracked';
}
function buildGitPaths(file: ProjectGitFileReference): string[] {
const paths = new Set<string>();
if (file.previousPath?.trim()) {
paths.add(file.previousPath.trim());
}
if (file.path.trim()) {
paths.add(file.path.trim());
}
return [...paths];
}
function uniqueGitPaths(files: readonly ProjectGitFileReference[]): string[] {
const paths = new Set<string>();
for (const file of files) {
for (const path of buildGitPaths(file)) {
paths.add(path);
}
}
return [...paths];
}
function isBinaryDiff(diff: string | undefined): boolean {
if (!diff) {
return false;
}
return diff.includes('GIT binary patch') || diff.includes('Binary files ');
}
function canRestoreBaseline(
baseline: ProjectGitBaselineFile | undefined,
): baseline is ProjectGitBaselineFile {
return baseline !== undefined
&& (baseline.untrackedContentBase64 !== undefined || baseline.combinedDiff !== undefined);
}
function baselineToPreview(
file: Pick<ProjectGitFileReference, 'path' | 'previousPath'>,
baseline: ProjectGitBaselineFile | undefined,
): ProjectGitDiffPreview | undefined {
if (!baseline) {
return undefined;
}
if (baseline.untrackedContentBase64 !== undefined) {
const contentBuffer = Buffer.from(baseline.untrackedContentBase64, 'base64');
return {
path: file.path,
previousPath: file.previousPath,
...(isUtf8(contentBuffer) ? { newFileContents: contentBuffer.toString('utf8') } : {}),
...(baseline.isBinary ? { isBinary: true } : {}),
};
}
if (baseline.combinedDiff === undefined && baseline.isBinary !== true) {
return undefined;
}
return {
path: file.path,
previousPath: file.previousPath,
...(baseline.combinedDiff && !isBinaryDiff(baseline.combinedDiff)
? { diff: baseline.combinedDiff }
: {}),
...(baseline.isBinary || isBinaryDiff(baseline.combinedDiff) ? { isBinary: true } : {}),
};
}
export class GitService {
constructor(private readonly runGitCommand: GitCommandRunner = defaultGitCommandRunner) {}
@@ -219,7 +432,7 @@ export class GitService {
this.tryRun(projectPath, ['log', '-1', '--format=%H%n%h%n%s%n%cI']),
]);
const { changedFileCount, changes } = parseChangeSummary(statusResult.stdout);
const { changedFileCount, changes } = parseWorkingTree(statusResult.stdout);
const upstream = upstreamResult.ok ? upstreamResult.stdout.trim() || undefined : undefined;
const aheadBehind = countsResult.ok ? parseAheadBehind(countsResult.stdout) : {};
@@ -238,11 +451,405 @@ export class GitService {
};
}
async describeProjectGitDetails(
projectPath: string,
scannedAt = nowIso(),
commitLimit = 20,
): Promise<ProjectGitDetails> {
const context = await this.describeProject(projectPath, scannedAt);
if (context.status !== 'ready') {
return {
scannedAt,
context,
branches: [],
recentCommits: [],
};
}
const [workingTree, branches, recentCommits] = await Promise.all([
this.captureWorkingTreeSnapshot(projectPath, scannedAt),
this.listBranches(projectPath),
this.listRecentCommits(projectPath, commitLimit),
]);
return {
scannedAt,
context,
workingTree: workingTree ?? undefined,
branches,
recentCommits,
};
}
async captureWorkingTreeSnapshot(
projectPath: string,
scannedAt = nowIso(),
): Promise<ProjectGitWorkingTreeSnapshot | undefined> {
const repoRootResult = await this.tryRun(projectPath, ['rev-parse', '--show-toplevel']);
if (!repoRootResult.ok) {
return undefined;
}
const statusResult = await this.tryRun(projectPath, ['status', '--porcelain=1', '--untracked-files=all']);
if (!statusResult.ok) {
return undefined;
}
const branchResult = await this.tryRun(projectPath, ['branch', '--show-current']);
const { changedFileCount, changes, files } = parseWorkingTree(statusResult.stdout);
return {
scannedAt,
repoRoot: repoRootResult.stdout.trim(),
branch: branchResult.ok ? parseBranch(branchResult.stdout) : undefined,
changedFileCount,
changes,
files,
};
}
async captureWorkingTreeBaseline(
projectPath: string,
snapshot?: ProjectGitWorkingTreeSnapshot,
): Promise<ProjectGitBaselineFile[]> {
const effectiveSnapshot = snapshot ?? await this.captureWorkingTreeSnapshot(projectPath);
if (!effectiveSnapshot || effectiveSnapshot.files.length === 0) {
return [];
}
const baselineFiles = await Promise.all(
effectiveSnapshot.files.map((file) => this.captureBaselineFile(projectPath, file)),
);
return baselineFiles.flatMap((file) => (file ? [file] : []));
}
async computeRunChangeSummary(
projectPath: string,
options: {
generatedAt?: string;
preRunSnapshot?: ProjectGitWorkingTreeSnapshot;
preRunBaselineFiles?: readonly ProjectGitBaselineFile[];
},
): Promise<ProjectGitRunChangeSummary | undefined> {
const generatedAt = options.generatedAt ?? nowIso();
const postRunSnapshot = await this.captureWorkingTreeSnapshot(projectPath, generatedAt);
if (!options.preRunSnapshot || !postRunSnapshot) {
return undefined;
}
const postRunBaselineFiles = await this.captureWorkingTreeBaseline(projectPath, postRunSnapshot);
return buildProjectGitRunChangeSummary({
generatedAt,
preRunSnapshot: options.preRunSnapshot,
preRunBaselineFiles: options.preRunBaselineFiles,
postRunSnapshot,
postRunBaselineFiles,
});
}
async getWorkingTreeFilePreview(
projectPath: string,
file: ProjectGitFileReference,
): Promise<ProjectGitDiffPreview | undefined> {
const snapshot = await this.captureWorkingTreeSnapshot(projectPath);
if (!snapshot) {
return undefined;
}
const matchedFile = snapshot.files.find((candidate) =>
candidate.path === file.path
|| candidate.previousPath === file.path
|| (file.previousPath !== undefined && candidate.path === file.previousPath)
|| (file.previousPath !== undefined && candidate.previousPath === file.previousPath));
if (!matchedFile) {
return undefined;
}
const baseline = await this.captureBaselineFile(projectPath, matchedFile);
return baselineToPreview(matchedFile, baseline);
}
async stageFiles(projectPath: string, files: readonly ProjectGitFileReference[]): Promise<void> {
const paths = uniqueGitPaths(files);
if (paths.length === 0) {
return;
}
await this.run(projectPath, ['add', '--', ...paths]);
}
async unstageFiles(projectPath: string, files: readonly ProjectGitFileReference[]): Promise<void> {
const paths = uniqueGitPaths(files);
if (paths.length === 0) {
return;
}
await this.run(projectPath, ['restore', '--staged', '--', ...paths]);
}
async commit(projectPath: string, message: string): Promise<ProjectGitCommitSummary> {
await this.run(projectPath, ['commit', '-m', message]);
const head = await this.getHeadCommit(projectPath);
if (!head) {
throw new Error('Git commit completed, but the new HEAD commit could not be resolved.');
}
return head;
}
async push(projectPath: string): Promise<void> {
await this.run(projectPath, ['push']);
}
async fetch(projectPath: string): Promise<void> {
await this.run(projectPath, ['fetch', '--all', '--prune']);
}
async pull(projectPath: string, rebase = false): Promise<void> {
await this.run(projectPath, rebase ? ['pull', '--rebase'] : ['pull']);
}
async createBranch(
projectPath: string,
name: string,
startPoint?: string,
checkout = true,
): Promise<void> {
const trimmedName = name.trim();
if (!trimmedName) {
throw new Error('A branch name is required.');
}
if (checkout) {
await this.run(
projectPath,
['switch', '-c', trimmedName, ...(startPoint?.trim() ? [startPoint.trim()] : [])],
);
return;
}
await this.run(
projectPath,
['branch', trimmedName, ...(startPoint?.trim() ? [startPoint.trim()] : [])],
);
}
async switchBranch(projectPath: string, name: string): Promise<void> {
const trimmedName = name.trim();
if (!trimmedName) {
throw new Error('A branch name is required.');
}
await this.run(projectPath, ['switch', trimmedName]);
}
async deleteBranch(projectPath: string, name: string, force = false): Promise<void> {
const trimmedName = name.trim();
if (!trimmedName) {
throw new Error('A branch name is required.');
}
await this.run(projectPath, ['branch', force ? '-D' : '-d', trimmedName]);
}
async listBranches(projectPath: string): Promise<ProjectGitBranchSummary[]> {
const result = await this.tryRun(projectPath, [
'for-each-ref',
'--format=%(refname:short)%00%(HEAD)%00%(upstream:short)',
'refs/heads',
]);
return result.ok ? parseBranchList(result.stdout) : [];
}
async listRecentCommits(projectPath: string, limit = 20): Promise<ProjectGitCommitLogEntry[]> {
const result = await this.tryRun(projectPath, [
'log',
`-n${Math.max(1, Math.round(limit))}`,
'--format=%H%x00%h%x00%an%x00%s%x00%cI%x00%D%x1e',
]);
return result.ok ? parseCommitLog(result.stdout) : [];
}
async discardRunChanges(
projectPath: string,
options: {
summary: ProjectGitRunChangeSummary;
preRunBaselineFiles?: readonly ProjectGitBaselineFile[];
files?: readonly ProjectGitFileReference[];
},
): Promise<void> {
const selectedFiles = options.files && options.files.length > 0
? options.summary.files.filter((candidate) =>
options.files?.some((selected) =>
selected.path === candidate.path
|| selected.path === candidate.previousPath
|| (selected.previousPath !== undefined && selected.previousPath === candidate.previousPath)))
: options.summary.files;
if (selectedFiles.length === 0) {
return;
}
const baselinesByPath = new Map(
(options.preRunBaselineFiles ?? []).map((file) => [file.path, file] satisfies [string, ProjectGitBaselineFile]),
);
for (const file of selectedFiles) {
if (file.origin === 'pre-existing') {
if (!file.canRevert) {
throw new Error(`Cannot restore "${file.path}" to its pre-run state because no restorable baseline was captured.`);
}
const baseline = baselinesByPath.get(file.previousPath ?? file.path) ?? baselinesByPath.get(file.path);
if (!canRestoreBaseline(baseline)) {
throw new Error(`Cannot restore "${file.path}" to its pre-run state because no restorable baseline was captured.`);
}
await this.restorePreExistingChange(projectPath, file, baseline);
continue;
}
await this.restoreRunCreatedChange(projectPath, file);
}
}
private async captureBaselineFile(
projectPath: string,
file: ProjectGitWorkingTreeFile,
): Promise<ProjectGitBaselineFile | undefined> {
if (!file.path.trim()) {
return undefined;
}
if (isPureUntrackedFile(file)) {
try {
const contents = await readFile(assertPathInsideBase(projectPath, file.path));
return {
path: file.path,
previousPath: file.previousPath,
untrackedContentBase64: contents.toString('base64'),
...(isUtf8(contents) ? {} : { isBinary: true }),
};
} catch {
return {
path: file.path,
previousPath: file.previousPath,
};
}
}
const diffPaths = buildGitPaths(file);
const diffResult = await this.tryRun(projectPath, [
'diff',
'--binary',
'--no-ext-diff',
'--no-renames',
'HEAD',
'--',
...diffPaths,
]);
if (!diffResult.ok) {
return {
path: file.path,
previousPath: file.previousPath,
};
}
return {
path: file.path,
previousPath: file.previousPath,
combinedDiff: diffResult.stdout.trim() ? diffResult.stdout : undefined,
...(isBinaryDiff(diffResult.stdout) ? { isBinary: true } : {}),
};
}
private async getHeadCommit(projectPath: string): Promise<ProjectGitCommitSummary | undefined> {
const result = await this.tryRun(projectPath, ['log', '-1', '--format=%H%n%h%n%s%n%cI']);
return result.ok ? parseHead(result.stdout) : undefined;
}
private async restoreRunCreatedChange(
projectPath: string,
file: ProjectGitRunChangedFile,
): Promise<void> {
if (file.kind === 'renamed' && file.previousPath) {
await this.restorePathFromHead(projectPath, file.previousPath);
await this.removePath(projectPath, file.path);
return;
}
if (file.kind === 'added' || file.kind === 'untracked' || file.kind === 'copied') {
await this.removePath(projectPath, file.path);
return;
}
await this.restorePathFromHead(projectPath, file.path);
}
private async restorePreExistingChange(
projectPath: string,
file: ProjectGitRunChangedFile,
baseline: ProjectGitBaselineFile,
): Promise<void> {
await this.restorePathFromHead(projectPath, baseline.path);
if (file.path !== baseline.path) {
await this.removePath(projectPath, file.path);
}
if (baseline.untrackedContentBase64 !== undefined) {
const contents = Buffer.from(baseline.untrackedContentBase64, 'base64');
await mkdir(dirname(join(projectPath, baseline.path)), { recursive: true });
await writeFile(join(projectPath, baseline.path), contents);
return;
}
if (baseline.combinedDiff) {
await this.applyPatch(projectPath, baseline.combinedDiff);
}
}
private async restorePathFromHead(projectPath: string, path: string): Promise<void> {
const result = await this.tryRun(projectPath, ['restore', '--source=HEAD', '--staged', '--worktree', '--', path]);
if (!result.ok && !isUnknownPathspec(result.error)) {
throw result.error;
}
}
private async removePath(projectPath: string, path: string): Promise<void> {
const unstageResult = await this.tryRun(projectPath, ['rm', '--cached', '--force', '--ignore-unmatch', '--', path]);
if (!unstageResult.ok && !isUnknownPathspec(unstageResult.error)) {
throw unstageResult.error;
}
await rm(assertPathInsideBase(projectPath, path), { force: true });
}
private async applyPatch(projectPath: string, diff: string): Promise<void> {
const tempDirectory = await mkdtemp(join(tmpdir(), 'aryx-git-patch-'));
const patchPath = join(tempDirectory, 'restore.diff');
try {
await writeFile(patchPath, diff, 'utf8');
await this.run(projectPath, ['apply', '--whitespace=nowarn', '--recount', patchPath]);
} finally {
await rm(tempDirectory, { force: true, recursive: true });
}
}
private async run(projectPath: string, args: string[]): Promise<string> {
try {
return await this.runGitCommand(projectPath, args);
} catch (error) {
throw createGitCommandFailure(projectPath, args, error);
}
}
private async tryRun(projectPath: string, args: string[]): Promise<GitCommandResult> {
try {
return {
ok: true,
stdout: await this.runGitCommand(projectPath, args),
stdout: await this.run(projectPath, args),
};
} catch (error) {
return {
+76 -21
View File
@@ -1,19 +1,47 @@
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;
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 +49,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,
onCreateScratchpad: () => {
showAndFocusWindow();
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 +102,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' });
}
@@ -64,7 +116,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();
@@ -79,6 +133,7 @@ app.on('activate', async () => {
});
app.on('before-quit', async () => {
globalHotkeyService?.dispose();
autoUpdateService?.dispose();
autoUpdateService = undefined;
systemTray?.dispose();
+217 -11
View File
@@ -5,13 +5,29 @@ import { ipcChannels } from '@shared/contracts/channels';
import type {
BranchSessionInput,
CancelSessionTurnInput,
CommitProjectGitChangesInput,
CreateSessionInput,
CreateWorkflowFromTemplateInput,
CreateWorkflowSessionInput,
CreateProjectGitBranchInput,
DismissSessionMcpAuthInput,
DismissSessionPlanReviewInput,
DeleteProjectGitBranchInput,
DeleteSessionInput,
DiscardSessionRunGitChangesInput,
EditAndResendSessionMessageInput,
ExportWorkflowInput,
ImportWorkflowInput,
ProjectGitDetailsInput,
ProjectGitFilePreviewInput,
ProjectGitFileSelectionInput,
ProjectGitInput,
PullProjectGitInput,
RegenerateSessionMessageInput,
ResolveWorkspaceDiscoveredToolingInput,
StartSessionMcpAuthInput,
SuggestProjectGitCommitMessageInput,
SwitchProjectGitBranchInput,
DuplicateSessionInput,
RenameSessionInput,
RescanProjectConfigsInput,
@@ -19,12 +35,12 @@ import type {
ResolveProjectDiscoveredToolingInput,
ResolveSessionApprovalInput,
ResolveSessionUserInputInput,
ResolveWorkspaceDiscoveredToolingInput,
SaveLspProfileInput,
SaveMcpServerInput,
SavePatternInput,
SaveWorkflowInput,
SaveWorkflowTemplateInput,
SaveWorkspaceAgentInput,
SendSessionMessageInput,
SetPatternFavoriteInput,
SetProjectAgentProfileEnabledInput,
SetSessionArchivedInput,
SetSessionInteractionModeInput,
@@ -35,14 +51,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, 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;
@@ -52,6 +72,12 @@ export function registerIpcHandlers(
service: AryxAppService,
autoUpdateService: AutoUpdateService,
): void {
window.on('focus', () => {
if (service.isGitAutoRefreshEnabled()) {
service.scheduleProjectGitRefresh();
}
});
ipcMain.handle(ipcChannels.describeSidecarCapabilities, () => service.describeSidecarCapabilities());
ipcMain.handle(ipcChannels.refreshSidecarCapabilities, () => service.refreshSidecarCapabilities());
ipcMain.handle(ipcChannels.loadWorkspace, () => service.loadWorkspace());
@@ -65,6 +91,12 @@ export function registerIpcHandlers(
ipcMain.handle(ipcChannels.refreshProjectGitContext, (_event, projectId?: string) =>
service.refreshProjectGitContext(projectId),
);
ipcMain.handle(ipcChannels.getProjectGitDetails, (_event, input: ProjectGitDetailsInput) =>
service.getProjectGitDetails(input.projectId, input.commitLimit),
);
ipcMain.handle(ipcChannels.getProjectGitFilePreview, (_event, input: ProjectGitFilePreviewInput) =>
service.getProjectGitFilePreview(input.projectId, input.file),
);
ipcMain.handle(ipcChannels.rescanProjectConfigs, (_event, input: RescanProjectConfigsInput) =>
service.rescanProjectConfigs(input.projectId),
);
@@ -83,10 +115,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);
@@ -105,6 +149,10 @@ export function registerIpcHandlers(
ipcChannels.setMinimizeToTray,
(_event, enabled: boolean) => service.setMinimizeToTray(enabled),
);
ipcMain.handle(
ipcChannels.setGitAutoRefreshEnabled,
(_event, enabled: boolean) => service.setGitAutoRefreshEnabled(enabled),
);
ipcMain.handle(ipcChannels.checkForUpdates, () => autoUpdateService.checkForUpdates());
ipcMain.handle(ipcChannels.installUpdate, () => {
autoUpdateService.installUpdate();
@@ -121,6 +169,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());
@@ -144,7 +198,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),
@@ -174,7 +231,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),
@@ -197,14 +260,60 @@ export function registerIpcHandlers(
ipcMain.handle(ipcChannels.startSessionMcpAuth, (_event, input: StartSessionMcpAuthInput) =>
service.startSessionMcpAuth(input.sessionId),
);
ipcMain.handle(
ipcChannels.discardSessionRunGitChanges,
(_event, input: DiscardSessionRunGitChangesInput) =>
service.discardSessionRunGitChanges(input.sessionId, input.runId, input.files),
);
ipcMain.handle(
ipcChannels.suggestProjectGitCommitMessage,
(_event, input: SuggestProjectGitCommitMessageInput) =>
service.suggestProjectGitCommitMessage(input.sessionId, input.runId, input.conventionalType),
);
ipcMain.handle(
ipcChannels.updateSessionModelConfig,
(_event, input: UpdateSessionModelConfigInput) =>
service.updateSessionModelConfig(input.sessionId, input.model, input.reasoningEffort),
);
ipcMain.handle(
ipcChannels.stageProjectGitFiles,
(_event, input: ProjectGitFileSelectionInput) => service.stageProjectGitFiles(input.projectId, input.files),
);
ipcMain.handle(
ipcChannels.unstageProjectGitFiles,
(_event, input: ProjectGitFileSelectionInput) => service.unstageProjectGitFiles(input.projectId, input.files),
);
ipcMain.handle(
ipcChannels.commitProjectGitChanges,
(_event, input: CommitProjectGitChangesInput) =>
service.commitProjectGitChanges(input.projectId, input.message, input.files, input.push),
);
ipcMain.handle(ipcChannels.pushProjectGit, (_event, input: ProjectGitInput) =>
service.pushProjectGit(input.projectId),
);
ipcMain.handle(ipcChannels.fetchProjectGit, (_event, input: ProjectGitInput) =>
service.fetchProjectGit(input.projectId),
);
ipcMain.handle(ipcChannels.pullProjectGit, (_event, input: PullProjectGitInput) =>
service.pullProjectGit(input.projectId, input.rebase),
);
ipcMain.handle(
ipcChannels.createProjectGitBranch,
(_event, input: CreateProjectGitBranchInput) =>
service.createProjectGitBranch(input.projectId, input.name, input.startPoint, input.checkout),
);
ipcMain.handle(
ipcChannels.switchProjectGitBranch,
(_event, input: SwitchProjectGitBranchInput) =>
service.switchProjectGitBranch(input.projectId, input.name),
);
ipcMain.handle(
ipcChannels.deleteProjectGitBranch,
(_event, input: DeleteProjectGitBranchInput) =>
service.deleteProjectGitBranch(input.projectId, input.name, input.force),
);
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());
@@ -243,4 +352,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);
}
});
}
+96 -51
View File
@@ -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(),
};
+414
View File
@@ -0,0 +1,414 @@
import type {
ApprovalRequestedEvent,
ExitPlanModeRequestedEvent,
McpOauthRequiredEvent,
UserInputRequestedEvent,
} from '@shared/contracts/sidecar';
import {
dequeuePendingApprovalState,
enqueuePendingApprovalState,
listPendingApprovals,
resolvePendingApproval,
resolveApprovalToolKey,
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 = resolveApprovalToolKey(approval.toolName, approval.permissionKind);
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 = resolveApprovalToolKey(queued.toolName, queued.permissionKind);
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,
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;
}
}
+4 -6
View File
@@ -23,6 +23,7 @@ type AutoUpdateListener = (...args: any[]) => void;
interface AutoUpdaterLike {
autoDownload: boolean;
autoInstallOnAppQuit: boolean;
forceDevUpdateConfig: boolean;
on(event: string, listener: AutoUpdateListener): this;
removeListener(event: string, listener: AutoUpdateListener): this;
checkForUpdates(): Promise<unknown>;
@@ -151,7 +152,7 @@ export class AutoUpdateService {
};
private readonly notAvailableListener = () => {
this.publishStatus({ state: 'idle' });
this.publishStatus({ state: 'up-to-date' });
};
private readonly progressListener = (progress: AutoUpdateProgressLike) => {
@@ -180,6 +181,7 @@ export class AutoUpdateService {
this.scheduler = options.scheduler ?? defaultScheduler;
this.updater.autoDownload = true;
this.updater.autoInstallOnAppQuit = false;
this.updater.forceDevUpdateConfig = !options.isPackaged;
this.updater.on('checking-for-update', this.checkingListener);
this.updater.on('update-available', this.availableListener);
@@ -190,7 +192,7 @@ export class AutoUpdateService {
}
start(): void {
if (this.started || !this.options.isPackaged) {
if (this.started) {
return;
}
@@ -215,10 +217,6 @@ export class AutoUpdateService {
}
async checkForUpdates(): Promise<UpdateStatus> {
if (!this.options.isPackaged) {
return this.getStatus();
}
if (this.pendingCheck) {
return this.pendingCheck;
}
@@ -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);
}
}
}
+272 -102
View File
@@ -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;
}
}
+306
View File
@@ -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;
}
}
+90
View File
@@ -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');
}
+309
View File
@@ -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