mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-23 21:18:40 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c24754749 | ||
|
|
2a7c627008 | ||
|
|
3a3e1d5eab | ||
|
|
9a9332369b | ||
|
|
67fb950e61 | ||
|
|
f7d376fc41 | ||
|
|
59ea028a24 | ||
|
|
c7910e6e4b | ||
|
|
418946b854 | ||
|
|
10fcaf2b92 | ||
|
|
34fb5420a4 | ||
|
|
05b3771544 | ||
|
|
9243ec10a2 | ||
|
|
1f49436d8a | ||
|
|
61b5788e78 | ||
|
|
875c6a3706 | ||
|
|
b4aaf62179 | ||
|
|
4ffd6ad8d4 | ||
|
|
c22bd32f97 | ||
|
|
dad9769fa1 | ||
|
|
56e2f8669c | ||
|
|
d6008da0af | ||
|
|
575aca360a | ||
|
|
649eeddff3 | ||
|
|
521be0de3c | ||
|
|
fdfa6f3046 | ||
|
|
0e9d77c745 | ||
|
|
bed6427dfc | ||
|
|
6068ee42e1 | ||
|
|
41d19e007d | ||
|
|
cc626a3152 | ||
|
|
8d08a1df1a | ||
|
|
08886f9078 | ||
|
|
1fdb1c27d5 | ||
|
|
4e34d2abfc | ||
|
|
7b9c4d140c | ||
|
|
fde82bef4d | ||
|
|
6ef44c6689 | ||
|
|
872476b2e3 | ||
|
|
b02a90a15f | ||
|
|
d3fb0a64c5 | ||
|
|
efcee8a621 | ||
|
|
543cf677ab | ||
|
|
952e69da9f | ||
|
|
fb5970cfa7 | ||
|
|
6714b93bed | ||
|
|
b038019954 | ||
|
|
ec92b61663 | ||
|
|
524380e2b5 | ||
|
|
5324a5121d | ||
|
|
e3660254df | ||
|
|
e36b00ff1d | ||
|
|
c70a5c6612 | ||
|
|
fa8f6ef4b3 | ||
|
|
b9e73831e8 | ||
|
|
0e2f9b8ae5 | ||
|
|
e46193ae53 | ||
|
|
008d8c1bd0 | ||
|
|
e85906669f | ||
|
|
2c165e453f | ||
|
|
931ec27f42 | ||
|
|
3e71de98e8 | ||
|
|
6d698ca233 | ||
|
|
574455729b | ||
|
|
36b8dd0c12 | ||
|
|
7a7b3fcdca | ||
|
|
794794afe4 | ||
|
|
9f85cce418 | ||
|
|
33773c868f | ||
|
|
507bd5408c | ||
|
|
7e37d75116 | ||
|
|
fd9cc03c5c | ||
|
|
8797186a73 | ||
|
|
573efff647 | ||
|
|
b583f39a38 | ||
|
|
3a639813a9 | ||
|
|
0a5fa81111 | ||
|
|
778c3b4a5b | ||
|
|
366200b29d | ||
|
|
cbc05c8f08 | ||
|
|
ecba1be92a | ||
|
|
3a71baf104 | ||
|
|
a3e0bd9244 | ||
|
|
d265db42e5 | ||
|
|
ad2eab73d7 | ||
|
|
e79cbe8df9 | ||
|
|
1ac1cc1d47 | ||
|
|
bf72315735 | ||
|
|
59170a794d | ||
|
|
3564df78d3 | ||
|
|
a68c9ee837 | ||
|
|
5ac6980b3f | ||
|
|
f4cb4c768b | ||
|
|
e265714225 | ||
|
|
805e369b67 | ||
|
|
69d0804161 | ||
|
|
059714326a | ||
|
|
b302ea5979 | ||
|
|
81eb8f7c82 | ||
|
|
e47835c1e8 | ||
|
|
55df0c3b61 | ||
|
|
4971dcf9fc | ||
|
|
f05ec8ac7f | ||
|
|
69ac454f29 | ||
|
|
7a32c9c0a3 | ||
|
|
871609db5b | ||
|
|
41e74c2fa9 | ||
|
|
ea9444ddac | ||
|
|
8f6830dca1 | ||
|
|
f011311514 | ||
|
|
19a764d297 | ||
|
|
4c3198a550 | ||
|
|
c0aaffa39f | ||
|
|
e39fffaf3b | ||
|
|
10316a2871 | ||
|
|
a18803758b | ||
|
|
6eadb36c10 | ||
|
|
bcbdd2ef29 | ||
|
|
5d69d9d855 | ||
|
|
aa7830f01a | ||
|
|
36b37e8915 | ||
|
|
dc69f8bf04 | ||
|
|
6f7cf60aa9 | ||
|
|
423d45fa1b | ||
|
|
13bcc44f1a | ||
|
|
1ceb3d5669 | ||
|
|
1dd13588a0 | ||
|
|
235ddf7e56 | ||
|
|
d7004ec2a9 | ||
|
|
0aed6240b9 | ||
|
|
11b36827f5 | ||
|
|
b434dd86b4 | ||
|
|
c2a691774a | ||
|
|
78949c5efd | ||
|
|
fb3e80ec47 | ||
|
|
5a71539705 | ||
|
|
906433f408 | ||
|
|
44d0ab07db | ||
|
|
49933f218b | ||
|
|
63acd9e87e | ||
|
|
ebb506a359 | ||
|
|
b25da56f5e | ||
|
|
30ff81009a | ||
|
|
9a261780c6 | ||
|
|
9b7e4dd6e9 | ||
|
|
4bc2c327f7 | ||
|
|
36126f1c74 | ||
|
|
bec50da2b4 | ||
|
|
535adc64be | ||
|
|
772c84fed3 | ||
|
|
9647b5fdb5 | ||
|
|
5946dc75ba | ||
|
|
59d3c81f9f | ||
|
|
deb5c96d58 | ||
|
|
56de8b7bd6 | ||
|
|
38dd358755 | ||
|
|
770a0f3529 | ||
|
|
e37d69bd63 | ||
|
|
c01a427d8a | ||
|
|
01b3949557 | ||
|
|
c01110979c | ||
|
|
a034b333b3 | ||
|
|
41289c960b | ||
|
|
33b293271e | ||
|
|
cbcf239a0a | ||
|
|
c702cf88e2 | ||
|
|
8a4d23c22a | ||
|
|
042cec6065 | ||
|
|
39fee48c0b | ||
|
|
27e784ab9b | ||
|
|
11a10ea53c | ||
|
|
3318a14d32 | ||
|
|
3b69a9c0f7 | ||
|
|
023ea9b3e4 | ||
|
|
bf2a454ef2 | ||
|
|
a1932788ae | ||
|
|
c3e611dc74 | ||
|
|
dcabc65dbf | ||
|
|
1068ed39e4 | ||
|
|
e956f8ea6c | ||
|
|
af69d494a5 | ||
|
|
e72bb7c7ca | ||
|
|
5f4318e761 | ||
|
|
d88ce0f00c | ||
|
|
15071fdc47 | ||
|
|
66b2a94977 | ||
|
|
4726e2acea | ||
|
|
c0a37b0cd4 | ||
|
|
7aae1b2cd5 | ||
|
|
a670817870 | ||
|
|
8813f9e90a | ||
|
|
20b400be56 | ||
|
|
05dded9b37 | ||
|
|
ea58f7d66a | ||
|
|
bb713f61be | ||
|
|
395965c639 | ||
|
|
be3f8d4cb3 | ||
|
|
f8b4c3cf4f | ||
|
|
2f1c5bc6d7 | ||
|
|
898e27e64d | ||
|
|
92832c6116 | ||
|
|
48efbf36f9 | ||
|
|
21f0ccb184 | ||
|
|
7921b6648f | ||
|
|
6d12cce836 | ||
|
|
e4142a6def | ||
|
|
edd4c7381a | ||
|
|
251316596c | ||
|
|
651a7d27fc | ||
|
|
cc13ed29f5 | ||
|
|
9ddd831b34 | ||
|
|
08876f694d | ||
|
|
216b17b2ac | ||
|
|
b985a06df3 | ||
|
|
169a9617c8 | ||
|
|
e38a663834 | ||
|
|
f0114058ba | ||
|
|
6505493735 | ||
|
|
b4b0bf54d2 | ||
|
|
8312a47bf1 | ||
|
|
3937904548 | ||
|
|
53a08e0ed4 | ||
|
|
5ad85db0f5 | ||
|
|
dd203ddde5 | ||
|
|
75b9ff667a | ||
|
|
f53907755a | ||
|
|
3c57cb6ded | ||
|
|
b946359c69 | ||
|
|
e4eb221308 | ||
|
|
036fb4d4fa | ||
|
|
6321f9192d | ||
|
|
147b437e36 | ||
|
|
fa5774cbc0 | ||
|
|
ae56d55c85 | ||
|
|
1fbfdbbac4 | ||
|
|
db9ffe8399 | ||
|
|
dff97efd58 | ||
|
|
f459cc7291 | ||
|
|
e13e3b818a | ||
|
|
f1fa52f9c3 | ||
|
|
0c2973c599 | ||
|
|
c1dab96bfd | ||
|
|
bb7e5d4108 | ||
|
|
b5ab92e444 | ||
|
|
689b335220 | ||
|
|
f04e3b9dcc | ||
|
|
3ec69d990b | ||
|
|
154787c336 | ||
|
|
9d65e3d209 | ||
|
|
7247a68f24 | ||
|
|
85a9327f65 | ||
|
|
2a952dfebe | ||
|
|
cf41279ff5 | ||
|
|
c02589f4c0 | ||
|
|
73595039fc | ||
|
|
81bddcbd63 | ||
|
|
53f1167681 | ||
|
|
4f7b479996 | ||
|
|
0fd7a04a51 | ||
|
|
6c6b49fde4 | ||
|
|
d73eaae30b | ||
|
|
1868a79d9a | ||
|
|
4f1ae86021 | ||
|
|
f0c2b4982b | ||
|
|
ac48aa58e0 | ||
|
|
f9757d5ce2 | ||
|
|
192c28f721 | ||
|
|
b670680a7d | ||
|
|
231be36e6c | ||
|
|
380e402512 | ||
|
|
c069b86add | ||
|
|
912677fba0 | ||
|
|
a1a044e7ca | ||
|
|
f15b1aedb1 | ||
|
|
2ae4bd01b4 | ||
|
|
4c01d8b1a7 | ||
|
|
13c543e2fd | ||
|
|
69562c19f3 | ||
|
|
7c1852f79f | ||
|
|
d7d1b33a53 |
@@ -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"
|
||||
+137
-23
@@ -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'
|
||||
@@ -96,20 +96,12 @@ jobs:
|
||||
include:
|
||||
- os: windows-latest
|
||||
label: Windows
|
||||
release_dir_name: Aryx-windows-x64
|
||||
asset_path: release/Aryx-windows-x64-setup.exe
|
||||
- os: macos-15-intel
|
||||
label: macOS (x64)
|
||||
release_dir_name: Aryx-macos-x64
|
||||
asset_path: release/Aryx-macos-x64.dmg
|
||||
- os: macos-15
|
||||
label: macOS (arm64)
|
||||
release_dir_name: Aryx-macos-arm64
|
||||
asset_path: release/Aryx-macos-arm64.dmg
|
||||
- os: ubuntu-latest
|
||||
label: Linux
|
||||
release_dir_name: Aryx-linux-x64
|
||||
asset_path: release/aryx-linux-x64.deb
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
@@ -123,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'
|
||||
@@ -134,20 +126,142 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Package current platform
|
||||
run: bun run package
|
||||
|
||||
- name: Ad-hoc sign macOS app bundle
|
||||
- name: Prepare Apple signing assets
|
||||
if: runner.os == 'macOS'
|
||||
run: codesign --force --deep --sign - "release/${{ matrix.release_dir_name }}/Aryx.app"
|
||||
|
||||
- name: Create platform installer
|
||||
run: bun run scripts/create-installer.ts
|
||||
|
||||
- name: Upload asset to GitHub release
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_CERT_P12_BASE64: ${{ secrets.APPLE_CERT_P12_BASE64 }}
|
||||
APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }}
|
||||
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
write_github_env() {
|
||||
local name="$1"
|
||||
local value="$2"
|
||||
local delimiter
|
||||
delimiter="ARYX_ENV_$(uuidgen | tr '[:lower:]' '[:upper:]')"
|
||||
{
|
||||
printf '%s<<%s\n' "$name" "$delimiter"
|
||||
printf '%s\n' "$value"
|
||||
printf '%s\n' "$delimiter"
|
||||
} >> "$GITHUB_ENV"
|
||||
}
|
||||
|
||||
if [[ -z "$APPLE_CERT_P12_BASE64" ]]; then
|
||||
echo "Missing required secret: APPLE_CERT_P12_BASE64" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$APPLE_CERT_PASSWORD" ]]; then
|
||||
echo "Missing required secret: APPLE_CERT_PASSWORD" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$APPLE_API_KEY_P8" ]]; then
|
||||
echo "Missing required secret: APPLE_API_KEY_P8" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$APPLE_API_KEY_ID" ]]; then
|
||||
echo "Missing required secret: APPLE_API_KEY_ID" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$APPLE_API_ISSUER" ]]; then
|
||||
echo "Missing required secret: APPLE_API_ISSUER" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$APPLE_TEAM_ID" ]]; then
|
||||
echo "Missing required secret: APPLE_TEAM_ID" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SOURCE_CERT_PATH="$RUNNER_TEMP/apple-signing-source.p12"
|
||||
CERT_PATH="$RUNNER_TEMP/apple-signing.p12"
|
||||
PEM_PATH="$RUNNER_TEMP/apple-signing.pem"
|
||||
PRECHECK_KEYCHAIN_PATH="$RUNNER_TEMP/apple-signing-preflight.keychain-db"
|
||||
PRECHECK_KEYCHAIN_PASSWORD="$(uuidgen)"
|
||||
API_KEY_PATH="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8"
|
||||
|
||||
cleanup_precheck_keychain() {
|
||||
security delete-keychain "$PRECHECK_KEYCHAIN_PATH" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
trap cleanup_precheck_keychain EXIT
|
||||
|
||||
CERT_PATH="$SOURCE_CERT_PATH" python3 - <<'PY'
|
||||
import base64
|
||||
import binascii
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
raw_value = os.environ["APPLE_CERT_P12_BASE64"]
|
||||
normalized_value = "".join(raw_value.split())
|
||||
if not normalized_value:
|
||||
raise SystemExit("APPLE_CERT_P12_BASE64 is empty after whitespace normalization")
|
||||
|
||||
try:
|
||||
decoded = base64.b64decode(normalized_value, validate=True)
|
||||
except binascii.Error:
|
||||
raise SystemExit("APPLE_CERT_P12_BASE64 is not valid base64")
|
||||
|
||||
if not decoded:
|
||||
raise SystemExit("Decoded Apple signing certificate is empty")
|
||||
|
||||
Path(os.environ["CERT_PATH"]).write_bytes(decoded)
|
||||
PY
|
||||
printf '%s' "$APPLE_API_KEY_P8" > "$API_KEY_PATH"
|
||||
|
||||
if [[ ! -s "$SOURCE_CERT_PATH" ]]; then
|
||||
echo "Decoded Apple signing certificate file is empty." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! openssl pkcs12 -in "$SOURCE_CERT_PATH" -noout -passin env:APPLE_CERT_PASSWORD >/dev/null 2>&1; then
|
||||
echo "Decoded Apple signing certificate could not be opened with APPLE_CERT_PASSWORD." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! openssl pkcs12 -in "$SOURCE_CERT_PATH" -passin env:APPLE_CERT_PASSWORD -nodes -out "$PEM_PATH" >/dev/null 2>&1; then
|
||||
echo "Decoded Apple signing certificate could not be converted to PEM." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! openssl pkcs12 -export -out "$CERT_PATH" -in "$PEM_PATH" -passout env:APPLE_CERT_PASSWORD -macalg sha1 -keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES >/dev/null 2>&1; then
|
||||
echo "Apple signing certificate could not be re-exported into a macOS-compatible PKCS#12." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -s "$CERT_PATH" ]]; then
|
||||
echo "Normalized Apple signing certificate file is empty." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! security create-keychain -p "$PRECHECK_KEYCHAIN_PASSWORD" "$PRECHECK_KEYCHAIN_PATH" >/dev/null 2>&1; then
|
||||
echo "Unable to create the macOS signing precheck keychain." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! security unlock-keychain -p "$PRECHECK_KEYCHAIN_PASSWORD" "$PRECHECK_KEYCHAIN_PATH" >/dev/null 2>&1; then
|
||||
echo "Unable to unlock the macOS signing precheck keychain." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! security import "$CERT_PATH" -k "$PRECHECK_KEYCHAIN_PATH" -P "$APPLE_CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/productsign >/dev/null 2>&1; then
|
||||
echo "Normalized Apple signing certificate is still not importable by macOS security." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -f "$SOURCE_CERT_PATH" "$PEM_PATH"
|
||||
cleanup_precheck_keychain
|
||||
trap - EXIT
|
||||
|
||||
write_github_env "CSC_LINK" "$CERT_PATH"
|
||||
write_github_env "CSC_KEY_PASSWORD" "$APPLE_CERT_PASSWORD"
|
||||
write_github_env "APPLE_API_KEY" "$API_KEY_PATH"
|
||||
write_github_env "APPLE_API_KEY_ID" "$APPLE_API_KEY_ID"
|
||||
write_github_env "APPLE_API_ISSUER" "$APPLE_API_ISSUER"
|
||||
write_github_env "APPLE_TEAM_ID" "$APPLE_TEAM_ID"
|
||||
|
||||
- name: Build and publish release artifacts
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG_NAME: ${{ github.ref_name }}
|
||||
ASSET_PATH: ${{ matrix.asset_path }}
|
||||
run: gh release upload "$TAG_NAME" "$ASSET_PATH" --clobber
|
||||
run: bun run publish-release
|
||||
|
||||
@@ -9,3 +9,4 @@ sidecar/**/obj/
|
||||
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
plan/
|
||||
@@ -42,6 +42,7 @@ These instructions apply to any automated or semi-automated agent working in thi
|
||||
- Treat tests as part of the implementation, not as follow-up work. Every fix, behavior change, and new feature must be covered by tests, and the relevant test suite must pass before the work is considered complete.
|
||||
- Always check whether `README.md` needs an update before handing work off. If the change affects user-facing behavior, workflows, prerequisites, installation, packaging, or product positioning, update the README in the same change.
|
||||
- Always check whether `ARCHITECTURE.md` needs an update before handing work off. If the change affects runtime boundaries, data flow, persistence, IPC, orchestration, tooling integration, packaging, or other material technical design, update `ARCHITECTURE.md` in the same change.
|
||||
- Always check whether the product website (`website/`) needs an update before handing work off. If the change introduces a major new feature, or a minor feature that is particularly interesting or noteworthy to end users, update the relevant website content in the same change. The website is a standalone Astro app under `website/` and validates with `bun run build` from that directory.
|
||||
- Do not ship quick fixes, hacks, or "temporary" patches as final solutions. Take the time to understand the problem, plan the change, and implement a maintainable solution that fits the codebase cleanly.
|
||||
- Remove code that is no longer necessary before handing work off. If an experiment, workaround, hotfix, helper, or test path does not end up being part of the final correct solution, delete it rather than leaving dead or misleading code behind.
|
||||
- Apply the same quality bar to feature work. Think through scope, edge cases, integration points, and long-term maintainability before implementing.
|
||||
@@ -153,13 +154,17 @@ Every interactive component must include basic accessibility:
|
||||
- Keep changes focused and reviewable. Avoid mixing unrelated concerns into a single change.
|
||||
- Always commit completed repository changes before handing work off. If unrelated pre-existing changes are present in the worktree, stop and ask the user how to proceed before creating the commit.
|
||||
- Do not mark work as done until both the implementation and its verification are complete.
|
||||
- **Never use unscoped glob patterns** (e.g. `**/*`) at or near the repository root. The repository contains large `node_modules/` directories that will cause glob operations to hang or exhaust resources. Always scope globs to a specific subdirectory (e.g. `src/**/*.ts`, `sidecar/src/**/*.cs`) or use `view` on known directories instead.
|
||||
|
||||
## 8. Planning requirements
|
||||
|
||||
- If a task spans both backend and frontend work, the implementation plan must be split into **Part 1 — Backend** and **Part 2 — Frontend**. The Frontend part will be launched manually by the user.
|
||||
- Backend work must be planned and executed first.
|
||||
- Before frontend work begins, backend work must produce a handover artifact in the session workspace `files\` directory. Do not put this handover document in the repository.
|
||||
> **Hard rule — no exceptions.** Every task that touches both backend (C# / sidecar) and frontend (TypeScript / renderer) code **must** produce a plan with exactly two phases: **Part 1 — Backend** and **Part 2 — Frontend**. A single combined plan that mixes backend and frontend work is never acceptable, even if the changes seem small or tightly coupled. When in doubt about whether a task spans both surfaces, treat it as spanning both and split the plan.
|
||||
|
||||
- **Part 1 — Backend** is always planned, implemented, tested, and committed first. No frontend work may begin until Part 1 is complete.
|
||||
- **Part 2 — Frontend** is launched manually by the user in a separate session. Do not start frontend implementation in the same session as backend work.
|
||||
- Before frontend work begins, backend work must produce a handover artifact in the session workspace `files\` directory. Do not put this handover document in the repository. The handover must describe every new or changed contract (DTOs, IPC messages, events, API shapes) that the frontend needs to consume.
|
||||
- The frontend phase must consume that backend handover artifact and build on it rather than rediscovering backend contracts from scratch.
|
||||
- If a task is frontend-only or backend-only, a two-part split is not required — but you must still confirm the scope before planning.
|
||||
|
||||
## 9. Validation checklist
|
||||
|
||||
|
||||
+114
-33
@@ -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
|
||||
|
||||
@@ -33,10 +33,11 @@ flowchart LR
|
||||
Renderer[Renderer UI<br/>React + Tailwind]
|
||||
Preload[Preload bridge]
|
||||
Main[Electron main process]
|
||||
Workspace[Workspace storage<br/>JSON + scratchpad files]
|
||||
Workspace[Workspace storage<br/>workspace.json + per-session scratchpad directories]
|
||||
Git[Local git repositories]
|
||||
Sidecar[.NET sidecar]
|
||||
Copilot[GitHub Copilot CLI<br/>+ agent runtime]
|
||||
Telemetry[OTLP collector<br/>Aspire Dashboard]
|
||||
OS[Native windowing<br/>and desktop integration]
|
||||
|
||||
User --> Renderer
|
||||
@@ -46,6 +47,7 @@ flowchart LR
|
||||
Main <--> Git
|
||||
Main <--> Sidecar
|
||||
Sidecar <--> Copilot
|
||||
Sidecar --> Telemetry
|
||||
Main <--> OS
|
||||
```
|
||||
|
||||
@@ -55,9 +57,9 @@ flowchart LR
|
||||
| --- | --- | --- | --- |
|
||||
| Renderer | Screens, interaction, local view composition, theme application | Filesystem, process spawning, raw Electron access, Copilot runtime | Typed preload API and pushed events |
|
||||
| Preload | Narrow bridge between browser context and Electron IPC | Business logic, persistence, orchestration | `ipcRenderer` / `ipcMain` |
|
||||
| Main process | Workspace mutation, persistence, git inspection, session lifecycle, native window state, sidecar lifecycle | UI rendering, LLM orchestration internals | IPC, filesystem, git CLI, stdio with sidecar |
|
||||
| Sidecar | Capability discovery, pattern validation, run execution, streaming deltas and activity | UI, workspace persistence, Electron APIs | Line-delimited JSON over stdio |
|
||||
| External systems | Git data, Copilot account/model access, OS window chrome | Application state and UI behavior | Controlled adapters owned by main or sidecar |
|
||||
| Main process | Workspace mutation, persistence, git inspection/write operations, run change attribution, commit workflow orchestration, session lifecycle, native window state, global hotkey registration, sidecar lifecycle, PTY-backed terminal lifecycle | UI rendering, LLM orchestration internals | IPC, filesystem, git CLI, stdio with sidecar, native child processes |
|
||||
| Sidecar | Capability discovery, workflow validation, run execution, provider event normalization, optional OTLP trace export, streaming deltas and activity, streamed text assembly | UI, workspace persistence, Electron APIs | Line-delimited JSON over stdio, OTLP to external collectors |
|
||||
| External systems | Git data, Copilot account/model access, telemetry collectors, OS window chrome | Application state and UI behavior | Controlled adapters owned by main or sidecar |
|
||||
|
||||
This split is the most important architectural feature in the app. It is what keeps the system understandable as more capabilities are added.
|
||||
|
||||
@@ -68,7 +70,7 @@ Aryx runs as a multi-process desktop application:
|
||||
1. The **renderer** displays the workspace and captures user intent.
|
||||
2. The **preload bridge** exposes a small, typed API into the browser context.
|
||||
3. The **main process** validates and mutates application state, persists it, and manages native integrations.
|
||||
4. The **sidecar** executes Copilot-backed turns and streams structured execution events back.
|
||||
4. The **sidecar** executes Copilot-backed turns, can export OpenTelemetry traces to an external collector, and streams structured execution events back.
|
||||
|
||||
The sidecar is intentionally separate from the Electron main process so that AI runtime concerns stay isolated from UI and persistence concerns.
|
||||
|
||||
@@ -89,7 +91,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 +99,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 +110,8 @@ This flow is important because it shows that Aryx is not architected as a simple
|
||||
The durable state of the app is a **workspace**. The workspace contains:
|
||||
|
||||
- connected projects
|
||||
- orchestration patterns
|
||||
- workflows
|
||||
- workflow templates
|
||||
- sessions
|
||||
- settings
|
||||
- run history
|
||||
@@ -122,34 +125,44 @@ Projects are the container for context. There are two kinds:
|
||||
- a special **scratchpad** project for lightweight work
|
||||
- normal **project-backed** entries pointing at local folders
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### Patterns
|
||||
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 describe how agents collaborate. The architecture supports:
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
||||
Patterns are shared application data, not renderer-only configuration. That means the same pattern definition can drive validation, persistence, UI rendering, and sidecar execution.
|
||||
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 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
Workflows are shared application data, not renderer-only configuration. The same workflow definition now drives validation, persistence, session execution, and sidecar orchestration.
|
||||
|
||||
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
|
||||
@@ -157,6 +170,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:
|
||||
@@ -166,8 +181,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
|
||||
|
||||
@@ -181,24 +197,58 @@ 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
|
||||
- toggle session tooling
|
||||
- update session approval overrides
|
||||
|
||||
The renderer does not reach into Electron or the filesystem directly. It talks through a constrained API surface.
|
||||
|
||||
The integrated terminal uses the same boundary. The renderer never opens a shell directly; it asks the main process to create or restart a PTY, sends fire-and-forget input and resize messages over IPC, and listens for streamed terminal data and exit events pushed back through preload. The `TerminalPanel` component manages an xterm.js terminal instance with a FitAddon, a drag-to-resize handle, and a header bar showing shell status.
|
||||
|
||||
### 2. Main process <-> sidecar
|
||||
|
||||
This is a structured stdio protocol used for:
|
||||
|
||||
- capability discovery
|
||||
- pattern validation
|
||||
- on-demand account quota lookup
|
||||
- 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.
|
||||
|
||||
Tool-call activity records can also be enriched with a stable `toolCallId` and aggregated file-change preview payloads (`path`, unified diff, and optional new-file contents). The sidecar derives those previews from Copilot SDK write permission requests, and the main process merges repeated write events by `toolCallId` into the persisted run timeline so future UI surfaces can render file previews without reinterpreting approval payloads.
|
||||
|
||||
The same boundary also supports server-scoped sidecar commands that do not require a live Copilot session. The new `get-quota` command uses the SDK's `account.getQuota` RPC to fetch account quota snapshots on demand, then returns them as a `quota-result` protocol event followed by the usual `command-complete` sentinel.
|
||||
|
||||
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 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
|
||||
|
||||
@@ -256,26 +306,39 @@ Tooling is deliberately split into two levels:
|
||||
|
||||
- **dynamic runtime tools** reported by the Copilot CLI, with a fallback catalog for startup/offline cases
|
||||
- **global definitions** for MCP servers and LSP profiles
|
||||
- **pattern defaults** where tool-call approval is enabled by default, plus which known runtime tools can bypass manual approval
|
||||
- **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
|
||||
- **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.
|
||||
|
||||
### Project awareness
|
||||
|
||||
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. 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.
|
||||
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
|
||||
- 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.
|
||||
|
||||
### Mid-turn steering
|
||||
|
||||
Aryx supports sending user messages while a turn is actively running. These messages are delivered with a `messageMode` flag (`immediate` or `enqueue`) that tells the sidecar to inject the content into the current Copilot session rather than starting a new turn. This enables real-time steering without waiting for turn completion. The main process allows the IPC call even when the session is in `running` status, and the renderer keeps the composer enabled throughout.
|
||||
|
||||
### Image attachments
|
||||
|
||||
User messages can carry image attachments as base64-encoded blobs. These flow from the renderer through IPC, the main process, and the sidecar protocol as `ChatMessageAttachmentDto` objects alongside the text content. The sidecar maps them into the Copilot SDK's `DataPart` model. Attachment metadata is persisted on the `ChatMessageRecord` so thumbnail previews render correctly when revisiting a session.
|
||||
|
||||
## Persistence and repair
|
||||
|
||||
Workspace persistence is intentionally simple: the app stores a durable workspace document and repairs or normalizes it when loading.
|
||||
@@ -291,7 +354,8 @@ That gives the system:
|
||||
|
||||
The main process owns desktop concerns such as:
|
||||
|
||||
- native window creation
|
||||
- native window creation (main window and quick prompt overlay)
|
||||
- global hotkey registration
|
||||
- title bar behavior
|
||||
- background process management
|
||||
- filesystem access
|
||||
@@ -299,17 +363,34 @@ 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
|
||||
- assembling a platform-specific release bundle
|
||||
- packaging platform artifacts with electron-builder
|
||||
|
||||
Release automation validates the app across Windows, macOS, and Linux, and tag-based releases publish platform bundles directly to GitHub Releases, including both macOS x64 and arm64 artifacts.
|
||||
electron-builder bundles the packaged Electron app, copies the published sidecar into `resources/sidecar`, produces Windows NSIS installers, macOS DMG + ZIP artifacts, and Linux AppImages, and uploads the release assets plus update metadata to GitHub Releases. Tagged macOS release jobs now materialize the certificate and App Store Connect key from repository secrets into temporary files on the runner, normalize the decoded PKCS#12 into a `security import`-compatible container, preflight that normalized certificate against a temporary keychain, export the standard `electron-builder` signing and notarization environment variables from those files, and package with checked-in hardened-runtime entitlements so native modules still run correctly under code signing. The main process consumes the published metadata through `electron-updater`, which checks GitHub Releases for packaged builds and can stage a restart-based update install.
|
||||
|
||||
Current Windows builds are unsigned, so the packaging config disables executable resource editing/signing and skips Windows update signature verification until a code-signing certificate is available. The packaging scripts also clear `release/` before each build so local packaging runs cannot accidentally mix stale artifacts with current ones.
|
||||
|
||||
This packaging model matches the runtime architecture: one desktop shell plus one dedicated AI execution process.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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>.
|
||||
@@ -8,113 +8,106 @@
|
||||
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> · <a href="https://aryx.app">Website</a> · <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.
|
||||
- **Stay organized** with persistent sessions you can rename, pin, archive, and return to later.
|
||||
- **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.
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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, 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 aspire # start the standalone Aspire Dashboard in Docker
|
||||
bun run dev:otel # start Aspire + Aryx dev with OTLP export enabled
|
||||
bun run package # package for current platform → release/
|
||||
bun run installer # create installable artifact
|
||||
bun run publish-release # publish to GitHub Releases
|
||||
bun run release # bump patch version, commit, tag, and push
|
||||
bun run release minor # bump minor version instead (use major for breaking releases)
|
||||
```
|
||||
|
||||
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.
|
||||
For local observability, `bun run dev:otel` starts the Aspire Dashboard UI at `http://localhost:18888`
|
||||
and configures the sidecar to export OpenTelemetry traces to `http://localhost:4317` over OTLP/gRPC.
|
||||
This dev workflow requires Docker.
|
||||
|
||||
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.
|
||||
Tagged releases use GitHub Actions to build and publish Windows (NSIS), macOS (DMG, signed + notarized), and Linux (AppImage) artifacts. The app uses `electron-updater` for in-app updates.
|
||||
The release helper expects a clean git worktree, an upstream branch configured for the current branch, and permission to push commits and tags.
|
||||
|
||||
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.
|
||||
## Trademarks
|
||||
|
||||
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`
|
||||
|
||||
GitHub Actions now runs validation on pushes and pull requests, and pushing a git tag creates a GitHub release with Windows, macOS (x64 and arm64), and Linux assets uploaded directly to the release.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1 @@
|
||||
ManifestDPIAware true
|
||||
@@ -1,9 +0,0 @@
|
||||
[Desktop Entry]
|
||||
Name=Aryx
|
||||
Comment=Copilot-powered agent workflow orchestrator
|
||||
Exec=/opt/aryx/Aryx %U
|
||||
Icon=aryx
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Development;
|
||||
StartupWMClass=Aryx
|
||||
@@ -1,112 +0,0 @@
|
||||
!include "MUI2.nsh"
|
||||
|
||||
;--- Product metadata (passed via /D defines from the build script) ---
|
||||
!ifndef PRODUCT_NAME
|
||||
!define PRODUCT_NAME "Aryx"
|
||||
!endif
|
||||
!ifndef PRODUCT_VERSION
|
||||
!define PRODUCT_VERSION "0.0.0"
|
||||
!endif
|
||||
!ifndef PRODUCT_PUBLISHER
|
||||
!define PRODUCT_PUBLISHER "David Kaya"
|
||||
!endif
|
||||
!ifndef SOURCE_DIR
|
||||
!error "SOURCE_DIR must be defined (path to the packaged app directory)."
|
||||
!endif
|
||||
!ifndef OUTPUT_PATH
|
||||
!error "OUTPUT_PATH must be defined (path to the output installer .exe)."
|
||||
!endif
|
||||
|
||||
;--- Installer attributes ---
|
||||
Name "${PRODUCT_NAME}"
|
||||
OutFile "${OUTPUT_PATH}"
|
||||
InstallDir "$LOCALAPPDATA\Programs\${PRODUCT_NAME}"
|
||||
InstallDirRegKey HKCU "Software\${PRODUCT_NAME}" "InstallDir"
|
||||
RequestExecutionLevel user
|
||||
SetCompressor /SOLID lzma
|
||||
|
||||
;--- Version info embedded in the installer EXE ---
|
||||
VIProductVersion "${PRODUCT_VERSION}.0"
|
||||
VIAddVersionKey "ProductName" "${PRODUCT_NAME}"
|
||||
VIAddVersionKey "ProductVersion" "${PRODUCT_VERSION}"
|
||||
VIAddVersionKey "FileDescription" "${PRODUCT_NAME} Setup"
|
||||
VIAddVersionKey "FileVersion" "${PRODUCT_VERSION}"
|
||||
VIAddVersionKey "CompanyName" "${PRODUCT_PUBLISHER}"
|
||||
VIAddVersionKey "LegalCopyright" "Copyright ${PRODUCT_PUBLISHER}"
|
||||
|
||||
;--- MUI configuration ---
|
||||
!define MUI_ABORTWARNING
|
||||
|
||||
;--- Installer pages ---
|
||||
!insertmacro MUI_PAGE_WELCOME
|
||||
!insertmacro MUI_PAGE_DIRECTORY
|
||||
!insertmacro MUI_PAGE_INSTFILES
|
||||
!define MUI_FINISHPAGE_RUN "$INSTDIR\${PRODUCT_NAME}.exe"
|
||||
!insertmacro MUI_PAGE_FINISH
|
||||
|
||||
;--- Uninstaller pages ---
|
||||
!insertmacro MUI_UNPAGE_CONFIRM
|
||||
!insertmacro MUI_UNPAGE_INSTFILES
|
||||
|
||||
;--- Language ---
|
||||
!insertmacro MUI_LANGUAGE "English"
|
||||
|
||||
;--- Installer section ---
|
||||
Section "Install"
|
||||
; Close any running instance
|
||||
ExecWait 'taskkill /F /IM ${PRODUCT_NAME}.exe' $0
|
||||
|
||||
SetOutPath "$INSTDIR"
|
||||
File /r "${SOURCE_DIR}\*.*"
|
||||
|
||||
; Write uninstaller
|
||||
WriteUninstaller "$INSTDIR\Uninstall.exe"
|
||||
|
||||
; Start Menu shortcut
|
||||
CreateDirectory "$SMPROGRAMS\${PRODUCT_NAME}"
|
||||
CreateShortcut "$SMPROGRAMS\${PRODUCT_NAME}\${PRODUCT_NAME}.lnk" "$INSTDIR\${PRODUCT_NAME}.exe"
|
||||
CreateShortcut "$SMPROGRAMS\${PRODUCT_NAME}\Uninstall ${PRODUCT_NAME}.lnk" "$INSTDIR\Uninstall.exe"
|
||||
|
||||
; Desktop shortcut
|
||||
CreateShortcut "$DESKTOP\${PRODUCT_NAME}.lnk" "$INSTDIR\${PRODUCT_NAME}.exe"
|
||||
|
||||
; Add/Remove Programs registry entry
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" \
|
||||
"DisplayName" "${PRODUCT_NAME}"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" \
|
||||
"UninstallString" '"$INSTDIR\Uninstall.exe"'
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" \
|
||||
"QuietUninstallString" '"$INSTDIR\Uninstall.exe" /S'
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" \
|
||||
"InstallLocation" "$INSTDIR"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" \
|
||||
"DisplayIcon" "$INSTDIR\${PRODUCT_NAME}.exe"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" \
|
||||
"Publisher" "${PRODUCT_PUBLISHER}"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" \
|
||||
"DisplayVersion" "${PRODUCT_VERSION}"
|
||||
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" \
|
||||
"NoModify" 1
|
||||
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" \
|
||||
"NoRepair" 1
|
||||
|
||||
; Store install directory for future upgrades
|
||||
WriteRegStr HKCU "Software\${PRODUCT_NAME}" "InstallDir" "$INSTDIR"
|
||||
SectionEnd
|
||||
|
||||
;--- Uninstaller section ---
|
||||
Section "Uninstall"
|
||||
; Close any running instance
|
||||
ExecWait 'taskkill /F /IM ${PRODUCT_NAME}.exe' $0
|
||||
|
||||
; Remove application files
|
||||
RMDir /r "$INSTDIR"
|
||||
|
||||
; Remove shortcuts
|
||||
RMDir /r "$SMPROGRAMS\${PRODUCT_NAME}"
|
||||
Delete "$DESKTOP\${PRODUCT_NAME}.lnk"
|
||||
|
||||
; Remove registry entries
|
||||
DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}"
|
||||
DeleteRegKey HKCU "Software\${PRODUCT_NAME}"
|
||||
SectionEnd
|
||||
@@ -0,0 +1,4 @@
|
||||
provider: github
|
||||
owner: davidkaya
|
||||
repo: aryx
|
||||
releaseType: release
|
||||
+13
-1
@@ -9,7 +9,7 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist-electron/main',
|
||||
},
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
plugins: [externalizeDepsPlugin({ exclude: ['@modelcontextprotocol/sdk'] })],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@main': resolve(__dirname, 'src/main'),
|
||||
@@ -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: {
|
||||
|
||||
+105
-9
@@ -1,15 +1,19 @@
|
||||
{
|
||||
"name": "aryx",
|
||||
"version": "1.0.0",
|
||||
"description": "Electron orchestrator for Copilot-powered agent workflows across multiple projects.",
|
||||
"version": "0.0.27",
|
||||
"description": "Orchestrator for Copilot-powered agent workflows across multiple projects.",
|
||||
"private": true,
|
||||
"main": "dist-electron/main/index.js",
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"dev:otel": "bun run scripts/dev-with-otel.ts",
|
||||
"aspire": "bun run scripts/launch-aspire-dashboard.ts",
|
||||
"build:electron": "electron-vite build",
|
||||
"build": "bun run build:electron && bun run sidecar:build",
|
||||
"package": "bun run build:electron && bun run sidecar:publish && bun run scripts/package-electron.ts",
|
||||
"installer": "bun run package && bun run scripts/create-installer.ts",
|
||||
"package": "bun run scripts/clean-release.ts && bun run build:electron && bun run sidecar:publish && electron-builder --dir --publish never",
|
||||
"installer": "bun run scripts/clean-release.ts && bun run build:electron && bun run sidecar:publish && electron-builder --publish never",
|
||||
"publish-release": "bun run scripts/clean-release.ts && bun run build:electron && bun run sidecar:publish && electron-builder --publish always",
|
||||
"release": "bun run scripts/release.ts",
|
||||
"preview": "electron-vite preview",
|
||||
"lsp:typescript": "typescript-language-server --stdio",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
@@ -33,7 +37,6 @@
|
||||
"packageManager": "bun@1.3.6",
|
||||
"devDependencies": {
|
||||
"@dagrejs/dagre": "^3.0.0",
|
||||
"@electron/asar": "^4.1.1",
|
||||
"@lexical/code": "0.42.0",
|
||||
"@lexical/headless": "0.42.0",
|
||||
"@lexical/link": "0.42.0",
|
||||
@@ -41,20 +44,22 @@
|
||||
"@lexical/markdown": "0.42.0",
|
||||
"@lexical/react": "0.42.0",
|
||||
"@lexical/rich-text": "0.42.0",
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "5.1.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"bun-types": "^1.3.11",
|
||||
"create-dmg": "^8.1.0",
|
||||
"electron": "^41.0.3",
|
||||
"electron-builder": "^26.8.1",
|
||||
"electron-vite": "^5.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lexical": "0.42.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"rcedit": "^5.0.2",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -62,9 +67,100 @@
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-language-server": "^5.1.3",
|
||||
"vite": "7.1.10"
|
||||
"vite": "7.1.10",
|
||||
"yaml": "^2.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"keytar": "^7.9.0"
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource-variable/outfit": "^5.2.8",
|
||||
"electron-updater": "^6.8.3",
|
||||
"keytar": "^7.9.0",
|
||||
"motion": "^12.38.0",
|
||||
"node-pty": "^1.1.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.davidkaya.aryx",
|
||||
"productName": "Aryx",
|
||||
"directories": {
|
||||
"buildResources": "assets",
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"package.json",
|
||||
"dist-electron/**/*",
|
||||
"dist/**/*",
|
||||
"assets/**/*"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "dist-sidecar",
|
||||
"to": "sidecar",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"asar": true,
|
||||
"asarUnpack": [
|
||||
"**/*.node"
|
||||
],
|
||||
"electronLanguages": [
|
||||
"en-US"
|
||||
],
|
||||
"electronUpdaterCompatibility": ">=2.16",
|
||||
"npmRebuild": false,
|
||||
"publish": {
|
||||
"provider": "github",
|
||||
"owner": "davidkaya",
|
||||
"repo": "aryx",
|
||||
"releaseType": "release"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis"
|
||||
],
|
||||
"icon": "assets/icons/windows/icon.ico",
|
||||
"artifactName": "Aryx-windows-${arch}.${ext}",
|
||||
"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"
|
||||
},
|
||||
"mac": {
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
],
|
||||
"icon": "assets/icons/macos/icon.icns",
|
||||
"category": "public.app-category.developer-tools",
|
||||
"hardenedRuntime": true,
|
||||
"entitlements": "assets/entitlements.mac.plist",
|
||||
"entitlementsInherit": "assets/entitlements.mac.inherit.plist",
|
||||
"gatekeeperAssess": false,
|
||||
"notarize": true,
|
||||
"artifactName": "Aryx-macos-${arch}.${ext}"
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage"
|
||||
],
|
||||
"icon": "assets/icons/linux/icons",
|
||||
"category": "Development",
|
||||
"artifactName": "Aryx-linux-${arch}.${ext}",
|
||||
"desktop": {
|
||||
"entry": {
|
||||
"Name": "Aryx",
|
||||
"Comment": "Copilot-powered agent workflow orchestrator",
|
||||
"StartupWMClass": "Aryx"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import net from 'node:net';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
export const aspireDashboardContainerName = 'aryx-aspire-dashboard';
|
||||
export const aspireDashboardImage = 'mcr.microsoft.com/dotnet/aspire-dashboard:latest';
|
||||
export const aspireDashboardUrls = {
|
||||
dashboard: 'http://localhost:18888',
|
||||
otlpGrpc: 'http://localhost:4317',
|
||||
otlpHttp: 'http://localhost:4318',
|
||||
} as const;
|
||||
|
||||
type ContainerState = 'missing' | 'running' | 'stopped';
|
||||
|
||||
export interface AspireDashboardHandle {
|
||||
readonly dashboardUrl: string;
|
||||
readonly otlpGrpcEndpoint: string;
|
||||
readonly otlpHttpEndpoint: string;
|
||||
readonly startedByScript: boolean;
|
||||
}
|
||||
|
||||
export function createAspireDashboardRunArgs(
|
||||
containerName = aspireDashboardContainerName,
|
||||
image = aspireDashboardImage,
|
||||
): string[] {
|
||||
return [
|
||||
'run',
|
||||
'--rm',
|
||||
'-d',
|
||||
'--name',
|
||||
containerName,
|
||||
'-p',
|
||||
'18888:18888',
|
||||
'-p',
|
||||
'4317:18889',
|
||||
'-p',
|
||||
'4318:18890',
|
||||
'-e',
|
||||
'ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true',
|
||||
image,
|
||||
];
|
||||
}
|
||||
|
||||
export function createOpenTelemetryEnvironment(
|
||||
baseEnvironment: NodeJS.ProcessEnv,
|
||||
endpoint: string = aspireDashboardUrls.otlpGrpc,
|
||||
): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...baseEnvironment,
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: endpoint,
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: 'grpc',
|
||||
};
|
||||
}
|
||||
|
||||
export async function startAspireDashboard(): Promise<AspireDashboardHandle> {
|
||||
await ensureDockerAvailable();
|
||||
|
||||
let startedByScript = false;
|
||||
const containerState = await getContainerState(aspireDashboardContainerName);
|
||||
if (containerState !== 'running') {
|
||||
if (containerState === 'stopped') {
|
||||
await runCommand('docker', ['rm', '-f', aspireDashboardContainerName], 'pipe');
|
||||
}
|
||||
|
||||
await runCommand('docker', createAspireDashboardRunArgs(), 'pipe');
|
||||
startedByScript = true;
|
||||
}
|
||||
|
||||
await waitForTcpPort(18888);
|
||||
await waitForTcpPort(4317);
|
||||
|
||||
return {
|
||||
dashboardUrl: aspireDashboardUrls.dashboard,
|
||||
otlpGrpcEndpoint: aspireDashboardUrls.otlpGrpc,
|
||||
otlpHttpEndpoint: aspireDashboardUrls.otlpHttp,
|
||||
startedByScript,
|
||||
};
|
||||
}
|
||||
|
||||
export async function stopAspireDashboard(): Promise<void> {
|
||||
const containerState = await getContainerState(aspireDashboardContainerName);
|
||||
if (containerState === 'missing') {
|
||||
return;
|
||||
}
|
||||
|
||||
await runCommand('docker', ['rm', '-f', aspireDashboardContainerName], 'pipe');
|
||||
}
|
||||
|
||||
async function ensureDockerAvailable(): Promise<void> {
|
||||
const cliVersion = await runCommandCapture('docker', ['--version']);
|
||||
if (cliVersion.exitCode !== 0) {
|
||||
throw new Error(
|
||||
'Docker is required to run the standalone Aspire Dashboard. Install Docker Desktop and ensure `docker` is available on PATH.',
|
||||
);
|
||||
}
|
||||
|
||||
if (await isDockerDaemonAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await hasDockerDesktopCli()) {
|
||||
console.log('Docker Desktop is not running. Starting it now...');
|
||||
await runCommand('docker', ['desktop', 'start', '--detach'], 'pipe');
|
||||
await waitForDockerDaemon();
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Docker is installed but the daemon is not running. Start Docker Desktop and rerun the Aspire command.',
|
||||
);
|
||||
}
|
||||
|
||||
async function getContainerState(containerName: string): Promise<ContainerState> {
|
||||
const result = await runCommandCapture('docker', [
|
||||
'container',
|
||||
'inspect',
|
||||
'--format',
|
||||
'{{.State.Status}}',
|
||||
containerName,
|
||||
]);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
return result.stdout.trim() === 'running' ? 'running' : 'stopped';
|
||||
}
|
||||
|
||||
async function waitForTcpPort(port: number, timeoutMs = 30_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await canConnect(port)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await delay(250);
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for localhost:${port} to accept connections.`);
|
||||
}
|
||||
|
||||
async function isDockerDaemonAvailable(): Promise<boolean> {
|
||||
const result = await runCommandCapture('docker', ['info', '--format', '{{.ServerVersion}}']);
|
||||
return result.exitCode === 0;
|
||||
}
|
||||
|
||||
async function hasDockerDesktopCli(): Promise<boolean> {
|
||||
const result = await runCommandCapture('docker', ['desktop', 'version']);
|
||||
return result.exitCode === 0;
|
||||
}
|
||||
|
||||
async function waitForDockerDaemon(timeoutMs = 120_000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await isDockerDaemonAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await delay(1_000);
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for Docker Desktop to start and accept API connections.');
|
||||
}
|
||||
|
||||
async function canConnect(port: number): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const socket = net.createConnection({ host: '127.0.0.1', port });
|
||||
const finalize = (connected: boolean) => {
|
||||
socket.removeAllListeners();
|
||||
socket.destroy();
|
||||
resolve(connected);
|
||||
};
|
||||
|
||||
socket.once('connect', () => finalize(true));
|
||||
socket.once('error', () => finalize(false));
|
||||
});
|
||||
}
|
||||
|
||||
async function runCommand(command: string, args: string[], stdio: 'inherit' | 'pipe'): Promise<void> {
|
||||
const result = await spawnProcess(command, args, stdio);
|
||||
if (result.exitCode === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.stderr.trim().length > 0) {
|
||||
throw new Error(result.stderr.trim());
|
||||
}
|
||||
|
||||
throw new Error(`${command} exited with code ${result.exitCode}.`);
|
||||
}
|
||||
|
||||
async function runCommandCapture(command: string, args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||
return spawnProcess(command, args, 'pipe');
|
||||
}
|
||||
|
||||
async function spawnProcess(
|
||||
command: string,
|
||||
args: string[],
|
||||
stdio: 'inherit' | 'pipe',
|
||||
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: stdio === 'inherit' ? 'inherit' : ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
if (stdio === 'pipe') {
|
||||
child.stdout?.on('data', (chunk: Buffer | string) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr?.on('data', (chunk: Buffer | string) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
}
|
||||
|
||||
child.on('error', reject);
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
reject(new Error(`${command} exited because of signal ${signal}.`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
exitCode: code ?? 1,
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { rm } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
||||
const repositoryRoot = resolve(scriptDirectory, '..');
|
||||
const releaseDirectory = resolve(repositoryRoot, 'release');
|
||||
|
||||
await rm(releaseDirectory, { recursive: true, force: true });
|
||||
@@ -1,238 +0,0 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { constants } from 'node:fs';
|
||||
import {
|
||||
access,
|
||||
cp,
|
||||
mkdir,
|
||||
readFile,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { productName, resolveReleaseTarget } from './releaseTarget';
|
||||
|
||||
function runCommand(command: string, args: string[], cwd: string): Promise<void> {
|
||||
return new Promise((resolvePromise, rejectPromise) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
child.on('error', rejectPromise);
|
||||
child.on('exit', (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolvePromise();
|
||||
return;
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
rejectPromise(new Error(`${command} exited because of signal ${signal}.`));
|
||||
return;
|
||||
}
|
||||
|
||||
rejectPromise(new Error(`${command} exited with code ${code ?? 'unknown'}.`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
||||
const repositoryRoot = resolve(scriptDirectory, '..');
|
||||
const releaseTarget = resolveReleaseTarget(process.platform, process.arch);
|
||||
const releaseRootDirectory = join(repositoryRoot, 'release');
|
||||
const packagedAppDirectory = join(releaseRootDirectory, releaseTarget.outputDirectoryName);
|
||||
const installerOutputPath = join(releaseRootDirectory, releaseTarget.installerAssetName);
|
||||
const installerAssetsDirectory = join(repositoryRoot, 'assets', 'installer');
|
||||
|
||||
async function readVersion(): Promise<string> {
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(join(repositoryRoot, 'package.json'), 'utf8'),
|
||||
) as { version: string };
|
||||
return packageJson.version;
|
||||
}
|
||||
|
||||
// --- Windows: NSIS installer ---
|
||||
|
||||
async function resolveNsisPath(): Promise<string> {
|
||||
const candidates = [
|
||||
'C:\\Program Files (x86)\\NSIS\\makensis.exe',
|
||||
'C:\\Program Files\\NSIS\\makensis.exe',
|
||||
'makensis',
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.includes('\\') && (await pathExists(candidate))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return 'makensis';
|
||||
}
|
||||
|
||||
async function createWindowsInstaller(version: string): Promise<void> {
|
||||
const nsisScript = join(installerAssetsDirectory, 'windows.nsi');
|
||||
const makensisPath = await resolveNsisPath();
|
||||
|
||||
await runCommand(
|
||||
makensisPath,
|
||||
[
|
||||
`/DPRODUCT_NAME=${productName}`,
|
||||
`/DPRODUCT_VERSION=${version}`,
|
||||
`/DSOURCE_DIR=${packagedAppDirectory}`,
|
||||
`/DOUTPUT_PATH=${installerOutputPath}`,
|
||||
nsisScript,
|
||||
],
|
||||
repositoryRoot,
|
||||
);
|
||||
}
|
||||
|
||||
// --- macOS: DMG disk image ---
|
||||
|
||||
async function createMacInstaller(): Promise<void> {
|
||||
const appBundleName = releaseTarget.appBundleName;
|
||||
if (!appBundleName) {
|
||||
throw new Error('macOS installer requires an app bundle name.');
|
||||
}
|
||||
|
||||
const appBundlePath = join(packagedAppDirectory, appBundleName);
|
||||
const createDmg = join(repositoryRoot, 'node_modules', '.bin', 'create-dmg');
|
||||
|
||||
try {
|
||||
await runCommand(
|
||||
createDmg,
|
||||
[
|
||||
'--overwrite',
|
||||
'--window-size', '600', '400',
|
||||
'--icon-size', '100',
|
||||
'--icon', appBundleName, '150', '200',
|
||||
'--app-drop-link', '450', '200',
|
||||
installerOutputPath,
|
||||
appBundlePath,
|
||||
],
|
||||
repositoryRoot,
|
||||
);
|
||||
} catch {
|
||||
// create-dmg exits with code 2 when code signing fails (expected without
|
||||
// a Developer ID certificate) but still produces a valid DMG. Check
|
||||
// whether the output file was created before treating this as an error.
|
||||
if (!(await pathExists(installerOutputPath))) {
|
||||
throw new Error('Failed to create DMG — output file was not produced.');
|
||||
}
|
||||
|
||||
console.log('DMG created (code signing skipped — ad-hoc only).');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Linux: .deb package ---
|
||||
|
||||
const linuxIconSizes = ['16x16', '32x32', '48x48', '64x64', '128x128', '256x256', '512x512'];
|
||||
|
||||
async function createLinuxInstaller(version: string): Promise<void> {
|
||||
const stagingDirectory = join(releaseRootDirectory, 'deb-staging');
|
||||
const debianDirectory = join(stagingDirectory, 'DEBIAN');
|
||||
const optDirectory = join(stagingDirectory, 'opt', 'aryx');
|
||||
const binDirectory = join(stagingDirectory, 'usr', 'bin');
|
||||
const applicationsDirectory = join(stagingDirectory, 'usr', 'share', 'applications');
|
||||
|
||||
await mkdir(debianDirectory, { recursive: true });
|
||||
await mkdir(binDirectory, { recursive: true });
|
||||
await mkdir(applicationsDirectory, { recursive: true });
|
||||
|
||||
// Copy packaged app into /opt/aryx/
|
||||
await cp(packagedAppDirectory, optDirectory, { recursive: true });
|
||||
|
||||
// Create symlink /usr/bin/aryx -> /opt/aryx/Aryx
|
||||
await symlink('/opt/aryx/Aryx', join(binDirectory, 'aryx'));
|
||||
|
||||
// Copy desktop entry
|
||||
await cp(
|
||||
join(installerAssetsDirectory, 'linux', 'aryx.desktop'),
|
||||
join(applicationsDirectory, 'aryx.desktop'),
|
||||
);
|
||||
|
||||
// Install icons into hicolor theme
|
||||
const sourceIconsDirectory = join(repositoryRoot, 'assets', 'icons', 'linux', 'icons');
|
||||
for (const size of linuxIconSizes) {
|
||||
const sourceIcon = join(sourceIconsDirectory, `${size}.png`);
|
||||
if (!(await pathExists(sourceIcon))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetIconDirectory = join(
|
||||
stagingDirectory, 'usr', 'share', 'icons', 'hicolor', size, 'apps',
|
||||
);
|
||||
await mkdir(targetIconDirectory, { recursive: true });
|
||||
await cp(sourceIcon, join(targetIconDirectory, 'aryx.png'));
|
||||
}
|
||||
|
||||
// Determine installed size (in KB)
|
||||
const { stdout } = await new Promise<{ stdout: string }>((resolvePromise, rejectPromise) => {
|
||||
const child = spawn('du', ['-sk', optDirectory], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
let out = '';
|
||||
child.stdout.on('data', (data: Buffer) => { out += data.toString(); });
|
||||
child.on('error', rejectPromise);
|
||||
child.on('exit', () => resolvePromise({ stdout: out }));
|
||||
});
|
||||
const installedSizeKb = parseInt(stdout.split('\t')[0] ?? '0', 10);
|
||||
|
||||
const debArch = releaseTarget.arch === 'x64' ? 'amd64' : 'arm64';
|
||||
|
||||
// Write DEBIAN/control
|
||||
const controlContent = [
|
||||
`Package: aryx`,
|
||||
`Version: ${version}`,
|
||||
`Section: devel`,
|
||||
`Priority: optional`,
|
||||
`Architecture: ${debArch}`,
|
||||
`Installed-Size: ${installedSizeKb}`,
|
||||
`Depends: libsecret-1-0`,
|
||||
`Maintainer: David Kaya`,
|
||||
`Description: ${productName} — Copilot-powered agent workflow orchestrator`,
|
||||
` Electron desktop app for orchestrating Copilot-driven agent workflows`,
|
||||
` across multiple projects.`,
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
await writeFile(join(debianDirectory, 'control'), controlContent);
|
||||
|
||||
// Build the .deb
|
||||
await runCommand(
|
||||
'dpkg-deb',
|
||||
['--build', '--root-owner-group', stagingDirectory, installerOutputPath],
|
||||
repositoryRoot,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Entry point ---
|
||||
|
||||
if (!(await pathExists(packagedAppDirectory))) {
|
||||
throw new Error(
|
||||
`Packaged app not found at ${packagedAppDirectory}. Run "bun run package" first.`,
|
||||
);
|
||||
}
|
||||
|
||||
const version = await readVersion();
|
||||
|
||||
switch (releaseTarget.platform) {
|
||||
case 'win32':
|
||||
await createWindowsInstaller(version);
|
||||
break;
|
||||
case 'darwin':
|
||||
await createMacInstaller();
|
||||
break;
|
||||
case 'linux':
|
||||
await createLinuxInstaller(version);
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(`Created installer: ${installerOutputPath}`);
|
||||
@@ -0,0 +1,57 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
createOpenTelemetryEnvironment,
|
||||
startAspireDashboard,
|
||||
stopAspireDashboard,
|
||||
} from './aspireDashboard';
|
||||
|
||||
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
||||
const repositoryRoot = resolve(scriptDirectory, '..');
|
||||
const signalExitCodes: Partial<Record<NodeJS.Signals, number>> = {
|
||||
SIGINT: 130,
|
||||
SIGTERM: 143,
|
||||
};
|
||||
|
||||
const dashboard = await startAspireDashboard();
|
||||
|
||||
console.log(`Aspire Dashboard: ${dashboard.dashboardUrl}`);
|
||||
console.log(`Aryx sidecar OTLP endpoint: ${dashboard.otlpGrpcEndpoint}`);
|
||||
|
||||
const child = spawn(process.execPath, ['run', 'dev'], {
|
||||
cwd: repositoryRoot,
|
||||
env: createOpenTelemetryEnvironment(process.env, dashboard.otlpGrpcEndpoint),
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||
process.on(signal, () => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill(signal);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let exitResult: { code: number | null; signal: NodeJS.Signals | null };
|
||||
|
||||
try {
|
||||
exitResult = await new Promise((resolve, reject) => {
|
||||
child.on('error', reject);
|
||||
child.on('exit', (code, signal) => {
|
||||
resolve({ code, signal });
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
if (dashboard.startedByScript) {
|
||||
await stopAspireDashboard();
|
||||
}
|
||||
}
|
||||
|
||||
if (exitResult.signal) {
|
||||
process.exitCode = signalExitCodes[exitResult.signal] ?? 1;
|
||||
} else {
|
||||
process.exitCode = exitResult.code ?? 1;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { startAspireDashboard } from './aspireDashboard';
|
||||
|
||||
const dashboard = await startAspireDashboard();
|
||||
|
||||
console.log(
|
||||
`${dashboard.startedByScript ? 'Started' : 'Reusing'} Aspire Dashboard at ${dashboard.dashboardUrl}`,
|
||||
);
|
||||
console.log(`OTLP/gRPC receiver: ${dashboard.otlpGrpcEndpoint}`);
|
||||
console.log(`OTLP/HTTP receiver: ${dashboard.otlpHttpEndpoint}`);
|
||||
@@ -1,332 +0,0 @@
|
||||
import { constants } from 'node:fs';
|
||||
import { access, chmod, cp, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { createPackageWithOptions } from '@electron/asar';
|
||||
|
||||
import {
|
||||
macBundleIdentifier,
|
||||
productName,
|
||||
resolveReleaseTarget,
|
||||
type ReleaseTarget,
|
||||
} from './releaseTarget';
|
||||
|
||||
interface PackageManifest {
|
||||
readonly name: string;
|
||||
readonly productName: string;
|
||||
readonly version: string;
|
||||
readonly description?: string;
|
||||
readonly main: string;
|
||||
readonly author?: string;
|
||||
readonly license?: string;
|
||||
}
|
||||
|
||||
interface RootPackageJson {
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
readonly description?: string;
|
||||
readonly main: string;
|
||||
readonly author?: string;
|
||||
readonly license?: string;
|
||||
readonly dependencies?: Record<string, string>;
|
||||
}
|
||||
|
||||
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
||||
const repositoryRoot = resolve(scriptDirectory, '..');
|
||||
const assetDirectory = join(repositoryRoot, 'assets');
|
||||
const genericIconPath = join(assetDirectory, 'icons', 'icon.png');
|
||||
const windowsIconPath = join(assetDirectory, 'icons', 'windows', 'icon.ico');
|
||||
const macosIconPath = join(assetDirectory, 'icons', 'macos', 'icon.icns');
|
||||
const rendererBuildDirectory = join(repositoryRoot, 'dist');
|
||||
const electronBuildDirectory = join(repositoryRoot, 'dist-electron');
|
||||
const releaseTarget = resolveReleaseTarget(process.platform, process.arch);
|
||||
const releaseRootDirectory = join(repositoryRoot, 'release');
|
||||
const outputDirectory = join(releaseRootDirectory, releaseTarget.outputDirectoryName);
|
||||
const electronDistributionDirectory = releaseTarget.platform === 'darwin'
|
||||
? join(repositoryRoot, 'node_modules', 'electron', 'dist', 'Electron.app')
|
||||
: join(repositoryRoot, 'node_modules', 'electron', 'dist');
|
||||
const publishedSidecarDirectory = join(repositoryRoot, 'dist-sidecar', releaseTarget.dotnetRuntime);
|
||||
|
||||
async function ensurePathExists(path: string, label: string): Promise<void> {
|
||||
try {
|
||||
await access(path, constants.F_OK);
|
||||
} catch {
|
||||
throw new Error(`${label} was not found at ${path}.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readJson<T>(path: string): Promise<T> {
|
||||
return JSON.parse(await readFile(path, 'utf8')) as T;
|
||||
}
|
||||
|
||||
async function collectRuntimeDependencies(): Promise<string[]> {
|
||||
const rootPackageJson = await readJson<RootPackageJson>(join(repositoryRoot, 'package.json'));
|
||||
const dependencies = new Set(Object.keys(rootPackageJson.dependencies ?? {}));
|
||||
const queue = [...dependencies];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const dependencyName = queue.shift();
|
||||
if (!dependencyName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dependencyPackageJsonPath = join(
|
||||
repositoryRoot,
|
||||
'node_modules',
|
||||
...dependencyName.split('/'),
|
||||
'package.json',
|
||||
);
|
||||
if (!(await pathExists(dependencyPackageJsonPath))) {
|
||||
dependencies.delete(dependencyName);
|
||||
continue;
|
||||
}
|
||||
|
||||
const dependencyPackageJson = await readJson<{
|
||||
readonly dependencies?: Record<string, string>;
|
||||
readonly optionalDependencies?: Record<string, string>;
|
||||
}>(dependencyPackageJsonPath);
|
||||
|
||||
for (const transitiveDependency of Object.keys({
|
||||
...(dependencyPackageJson.dependencies ?? {}),
|
||||
...(dependencyPackageJson.optionalDependencies ?? {}),
|
||||
})) {
|
||||
if (!dependencies.has(transitiveDependency)) {
|
||||
dependencies.add(transitiveDependency);
|
||||
queue.push(transitiveDependency);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...dependencies].sort();
|
||||
}
|
||||
|
||||
async function copyRuntimeDependencies(
|
||||
packagedAppDirectory: string,
|
||||
dependencyNames: string[],
|
||||
): Promise<void> {
|
||||
const packagedNodeModulesDirectory = join(packagedAppDirectory, 'node_modules');
|
||||
await mkdir(packagedNodeModulesDirectory, { recursive: true });
|
||||
|
||||
for (const dependencyName of dependencyNames) {
|
||||
const dependencyPathParts = dependencyName.split('/');
|
||||
const sourceDirectory = join(repositoryRoot, 'node_modules', ...dependencyPathParts);
|
||||
const targetDirectory = join(packagedNodeModulesDirectory, ...dependencyPathParts);
|
||||
await mkdir(dirname(targetDirectory), { recursive: true });
|
||||
await cp(sourceDirectory, targetDirectory, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function writePackagedManifest(packagedAppDirectory: string): Promise<PackageManifest> {
|
||||
const sourcePackageJson = await readJson<RootPackageJson>(join(repositoryRoot, 'package.json'));
|
||||
const packagedManifest: PackageManifest = {
|
||||
name: sourcePackageJson.name,
|
||||
productName,
|
||||
version: sourcePackageJson.version,
|
||||
description: sourcePackageJson.description,
|
||||
main: sourcePackageJson.main,
|
||||
author: sourcePackageJson.author,
|
||||
license: sourcePackageJson.license,
|
||||
};
|
||||
|
||||
await writeFile(
|
||||
join(packagedAppDirectory, 'package.json'),
|
||||
`${JSON.stringify(packagedManifest, null, 2)}\n`,
|
||||
);
|
||||
|
||||
return packagedManifest;
|
||||
}
|
||||
|
||||
async function copyApplicationPayload(
|
||||
packagedAppDirectory: string,
|
||||
outputResourcesDirectory: string,
|
||||
dependencyNames: string[],
|
||||
): Promise<PackageManifest> {
|
||||
await mkdir(packagedAppDirectory, { recursive: true });
|
||||
|
||||
const manifest = await writePackagedManifest(packagedAppDirectory);
|
||||
await Promise.all([
|
||||
cp(assetDirectory, join(packagedAppDirectory, 'assets'), { recursive: true }),
|
||||
cp(rendererBuildDirectory, join(packagedAppDirectory, 'dist'), { recursive: true }),
|
||||
cp(electronBuildDirectory, join(packagedAppDirectory, 'dist-electron'), { recursive: true }),
|
||||
cp(publishedSidecarDirectory, join(outputResourcesDirectory, 'sidecar'), { recursive: true }),
|
||||
]);
|
||||
|
||||
await copyRuntimeDependencies(packagedAppDirectory, dependencyNames);
|
||||
|
||||
const asarPath = join(outputResourcesDirectory, 'app.asar');
|
||||
await createPackageWithOptions(packagedAppDirectory, asarPath, {
|
||||
unpack: '**/*.node',
|
||||
});
|
||||
await rm(packagedAppDirectory, { recursive: true });
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
async function ensureExecutable(path: string, mode = 0o755): Promise<void> {
|
||||
await chmod(path, mode);
|
||||
}
|
||||
|
||||
function replacePlistValue(plistContents: string, key: string, value: string): string {
|
||||
const pattern = new RegExp(`(<key>${key}</key>\\s*<string>)([^<]*)(</string>)`);
|
||||
if (!pattern.test(plistContents)) {
|
||||
throw new Error(`Could not find ${key} in macOS Info.plist.`);
|
||||
}
|
||||
|
||||
return plistContents.replace(pattern, `$1${value}$3`);
|
||||
}
|
||||
|
||||
async function applyMacMetadata(appBundleDirectory: string, version: string): Promise<void> {
|
||||
const infoPlistPath = join(appBundleDirectory, 'Contents', 'Info.plist');
|
||||
let infoPlistContents = await readFile(infoPlistPath, 'utf8');
|
||||
infoPlistContents = replacePlistValue(infoPlistContents, 'CFBundleDisplayName', productName);
|
||||
infoPlistContents = replacePlistValue(infoPlistContents, 'CFBundleExecutable', productName);
|
||||
infoPlistContents = replacePlistValue(infoPlistContents, 'CFBundleIconFile', 'icon.icns');
|
||||
infoPlistContents = replacePlistValue(infoPlistContents, 'CFBundleIdentifier', macBundleIdentifier);
|
||||
infoPlistContents = replacePlistValue(infoPlistContents, 'CFBundleName', productName);
|
||||
infoPlistContents = replacePlistValue(infoPlistContents, 'CFBundleShortVersionString', version);
|
||||
infoPlistContents = replacePlistValue(infoPlistContents, 'CFBundleVersion', version);
|
||||
await writeFile(infoPlistPath, infoPlistContents);
|
||||
|
||||
const sourceExecutablePath = join(appBundleDirectory, 'Contents', 'MacOS', 'Electron');
|
||||
const targetExecutablePath = join(appBundleDirectory, 'Contents', 'MacOS', productName);
|
||||
await rename(sourceExecutablePath, targetExecutablePath);
|
||||
await ensureExecutable(targetExecutablePath);
|
||||
await cp(macosIconPath, join(appBundleDirectory, 'Contents', 'Resources', 'icon.icns'));
|
||||
}
|
||||
|
||||
async function stripUnneededElectronFiles(electronOutputDirectory: string): Promise<void> {
|
||||
const filesToRemove = ['LICENSES.chromium.html', 'LICENSE'];
|
||||
const resourcesToRemove = ['default_app.asar'];
|
||||
await Promise.all([
|
||||
...filesToRemove.map((file) => rm(join(electronOutputDirectory, file), { force: true })),
|
||||
...resourcesToRemove.map((file) =>
|
||||
rm(join(electronOutputDirectory, 'resources', file), { force: true }),
|
||||
),
|
||||
]);
|
||||
|
||||
const localesDirectory = join(electronOutputDirectory, 'locales');
|
||||
if (await pathExists(localesDirectory)) {
|
||||
const localeFiles = await readdir(localesDirectory);
|
||||
await Promise.all(
|
||||
localeFiles
|
||||
.filter((file) => file !== 'en-US.pak')
|
||||
.map((file) => rm(join(localesDirectory, file))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function stripMacElectronFiles(resourcesDirectory: string): Promise<void> {
|
||||
await rm(join(resourcesDirectory, 'LICENSES.chromium.html'), { force: true });
|
||||
|
||||
const entries = await readdir(resourcesDirectory);
|
||||
const unusedLproj = entries.filter(
|
||||
(entry) => entry.endsWith('.lproj') && entry !== 'en.lproj',
|
||||
);
|
||||
await Promise.all(
|
||||
unusedLproj.map((dir) => rm(join(resourcesDirectory, dir), { recursive: true })),
|
||||
);
|
||||
}
|
||||
|
||||
async function packageWindows(dependencyNames: string[]): Promise<void> {
|
||||
const packagedExecutablePath = join(outputDirectory, `${productName}.exe`);
|
||||
const packagedAppDirectory = join(outputDirectory, 'resources', 'app');
|
||||
const outputResourcesDirectory = join(outputDirectory, 'resources');
|
||||
|
||||
await cp(electronDistributionDirectory, outputDirectory, { recursive: true });
|
||||
await stripUnneededElectronFiles(outputDirectory);
|
||||
await rename(join(outputDirectory, 'electron.exe'), packagedExecutablePath);
|
||||
await copyApplicationPayload(packagedAppDirectory, outputResourcesDirectory, dependencyNames);
|
||||
|
||||
const { rcedit } = await import('rcedit');
|
||||
await rcedit(packagedExecutablePath, { icon: windowsIconPath });
|
||||
}
|
||||
|
||||
async function packageMac(dependencyNames: string[]): Promise<void> {
|
||||
const appBundleName = releaseTarget.appBundleName;
|
||||
if (!appBundleName) {
|
||||
throw new Error('macOS packaging requires an app bundle name.');
|
||||
}
|
||||
|
||||
const appBundleDirectory = join(outputDirectory, appBundleName);
|
||||
const packagedAppDirectory = join(appBundleDirectory, 'Contents', 'Resources', 'app');
|
||||
const outputResourcesDirectory = join(appBundleDirectory, 'Contents', 'Resources');
|
||||
|
||||
await cp(electronDistributionDirectory, appBundleDirectory, { recursive: true });
|
||||
await stripMacElectronFiles(join(appBundleDirectory, 'Contents', 'Resources'));
|
||||
const manifest = await copyApplicationPayload(packagedAppDirectory, outputResourcesDirectory, dependencyNames);
|
||||
await applyMacMetadata(appBundleDirectory, manifest.version);
|
||||
await ensureExecutable(join(outputResourcesDirectory, 'sidecar', releaseTarget.sidecarExecutableName));
|
||||
}
|
||||
|
||||
async function packageLinux(dependencyNames: string[]): Promise<void> {
|
||||
const packagedExecutableName = releaseTarget.packagedExecutableName;
|
||||
if (!packagedExecutableName) {
|
||||
throw new Error('Linux packaging requires a packaged executable name.');
|
||||
}
|
||||
|
||||
const packagedExecutablePath = join(outputDirectory, packagedExecutableName);
|
||||
const packagedAppDirectory = join(outputDirectory, 'resources', 'app');
|
||||
const outputResourcesDirectory = join(outputDirectory, 'resources');
|
||||
const chromeSandboxPath = join(outputDirectory, 'chrome-sandbox');
|
||||
|
||||
await cp(electronDistributionDirectory, outputDirectory, { recursive: true });
|
||||
await stripUnneededElectronFiles(outputDirectory);
|
||||
await rename(join(outputDirectory, 'electron'), packagedExecutablePath);
|
||||
await ensureExecutable(packagedExecutablePath);
|
||||
await copyApplicationPayload(packagedAppDirectory, outputResourcesDirectory, dependencyNames);
|
||||
await ensureExecutable(join(outputResourcesDirectory, 'sidecar', releaseTarget.sidecarExecutableName));
|
||||
|
||||
if (await pathExists(chromeSandboxPath)) {
|
||||
await chmod(chromeSandboxPath, 0o4755);
|
||||
}
|
||||
}
|
||||
|
||||
async function packageCurrentPlatform(target: ReleaseTarget, dependencyNames: string[]): Promise<void> {
|
||||
switch (target.platform) {
|
||||
case 'win32':
|
||||
await packageWindows(dependencyNames);
|
||||
return;
|
||||
case 'darwin':
|
||||
await packageMac(dependencyNames);
|
||||
return;
|
||||
case 'linux':
|
||||
await packageLinux(dependencyNames);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
ensurePathExists(assetDirectory, 'Application assets'),
|
||||
ensurePathExists(genericIconPath, 'Source application icon'),
|
||||
ensurePathExists(electronDistributionDirectory, 'Electron runtime'),
|
||||
ensurePathExists(rendererBuildDirectory, 'Renderer build output'),
|
||||
ensurePathExists(electronBuildDirectory, 'Electron build output'),
|
||||
ensurePathExists(publishedSidecarDirectory, 'Published sidecar output'),
|
||||
]);
|
||||
|
||||
if (releaseTarget.platform === 'win32') {
|
||||
await ensurePathExists(windowsIconPath, 'Windows application icon');
|
||||
}
|
||||
|
||||
if (releaseTarget.platform === 'darwin') {
|
||||
await ensurePathExists(macosIconPath, 'macOS application icon');
|
||||
}
|
||||
|
||||
const runtimeDependencies = await collectRuntimeDependencies();
|
||||
|
||||
await rm(outputDirectory, { recursive: true, force: true });
|
||||
await mkdir(releaseRootDirectory, { recursive: true });
|
||||
await packageCurrentPlatform(releaseTarget, runtimeDependencies);
|
||||
|
||||
console.log(`Packaged ${productName} for ${releaseTarget.platformLabel} to ${outputDirectory}`);
|
||||
console.log(`Bundled ${runtimeDependencies.length} runtime dependencies and the self-contained .NET sidecar.`);
|
||||
@@ -3,8 +3,6 @@ import { rm } from 'node:fs/promises';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { resolveReleaseTarget } from './releaseTarget';
|
||||
|
||||
function runCommand(command: string, args: string[], cwd: string): Promise<void> {
|
||||
return new Promise((resolvePromise, rejectPromise) => {
|
||||
const child = spawn(command, args, {
|
||||
@@ -29,9 +27,40 @@ function runCommand(command: string, args: string[], cwd: string): Promise<void>
|
||||
});
|
||||
}
|
||||
|
||||
type SupportedPlatform = 'win32' | 'darwin' | 'linux';
|
||||
type SupportedArch = 'x64' | 'arm64';
|
||||
|
||||
function resolveDotnetRuntime(platform: NodeJS.Platform, arch: NodeJS.Architecture): `${string}-${SupportedArch}` {
|
||||
if (arch !== 'x64' && arch !== 'arm64') {
|
||||
throw new Error(`Unsupported architecture for sidecar publish: ${arch}`);
|
||||
}
|
||||
|
||||
switch (platform) {
|
||||
case 'win32':
|
||||
return `win-${arch}`;
|
||||
case 'darwin':
|
||||
return `osx-${arch}`;
|
||||
case 'linux':
|
||||
return `linux-${arch}`;
|
||||
default:
|
||||
throw new Error(`Unsupported platform for sidecar publish: ${platform}`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePlatformLabel(platform: SupportedPlatform): 'windows' | 'macos' | 'linux' {
|
||||
switch (platform) {
|
||||
case 'win32':
|
||||
return 'windows';
|
||||
case 'darwin':
|
||||
return 'macos';
|
||||
case 'linux':
|
||||
return 'linux';
|
||||
}
|
||||
}
|
||||
|
||||
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
||||
const repositoryRoot = resolve(scriptDirectory, '..');
|
||||
const releaseTarget = resolveReleaseTarget(process.platform, process.arch);
|
||||
const dotnetRuntime = resolveDotnetRuntime(process.platform, process.arch);
|
||||
const sidecarProjectPath = join(
|
||||
repositoryRoot,
|
||||
'sidecar',
|
||||
@@ -39,7 +68,7 @@ const sidecarProjectPath = join(
|
||||
'Aryx.AgentHost',
|
||||
'Aryx.AgentHost.csproj',
|
||||
);
|
||||
const outputDirectory = join(repositoryRoot, 'dist-sidecar', releaseTarget.dotnetRuntime);
|
||||
const outputDirectory = join(repositoryRoot, 'dist-sidecar');
|
||||
|
||||
await rm(outputDirectory, { recursive: true, force: true });
|
||||
|
||||
@@ -51,7 +80,7 @@ await runCommand(
|
||||
'-c',
|
||||
'Release',
|
||||
'-r',
|
||||
releaseTarget.dotnetRuntime,
|
||||
dotnetRuntime,
|
||||
'--self-contained',
|
||||
'true',
|
||||
'-p:DebugType=None',
|
||||
@@ -65,4 +94,4 @@ await runCommand(
|
||||
repositoryRoot,
|
||||
);
|
||||
|
||||
console.log(`Published sidecar for ${releaseTarget.platformLabel} (${releaseTarget.dotnetRuntime}) to ${outputDirectory}`);
|
||||
console.log(`Published sidecar for ${resolvePlatformLabel(process.platform as SupportedPlatform)} (${dotnetRuntime}) to ${outputDirectory}`);
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
||||
const repositoryRoot = resolve(scriptDirectory, '..');
|
||||
const packageJsonPath = resolve(repositoryRoot, 'package.json');
|
||||
|
||||
export type ReleaseBump = 'patch' | 'minor' | 'major';
|
||||
|
||||
interface PackageJsonShape {
|
||||
version?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface GitCommandResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export type GitRunner = (args: string[]) => Promise<GitCommandResult>;
|
||||
|
||||
export interface ReleaseDependencies {
|
||||
readText(path: string): Promise<string>;
|
||||
writeText(path: string, content: string): Promise<void>;
|
||||
runGit: GitRunner;
|
||||
log(message: string): void;
|
||||
}
|
||||
|
||||
export interface ReleaseWorkflowOptions {
|
||||
repositoryRoot: string;
|
||||
packageJsonPath: string;
|
||||
bumpArg?: string;
|
||||
}
|
||||
|
||||
export interface ReleaseResult {
|
||||
bump: ReleaseBump;
|
||||
currentVersion: string;
|
||||
nextVersion: string;
|
||||
tagName: string;
|
||||
localBranch: string;
|
||||
remote: string;
|
||||
upstreamBranch: string;
|
||||
}
|
||||
|
||||
interface UpstreamTarget {
|
||||
remote: string;
|
||||
branch: string;
|
||||
}
|
||||
|
||||
function createGitRunner(cwd: string): GitRunner {
|
||||
return async (args) =>
|
||||
new Promise((resolvePromise, rejectPromise) => {
|
||||
const child = spawn('git', args, {
|
||||
cwd,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout?.on('data', (chunk: Buffer | string) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (chunk: Buffer | string) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
child.on('error', rejectPromise);
|
||||
child.on('close', (code, signal) => {
|
||||
if (signal) {
|
||||
rejectPromise(new Error(`git ${args.join(' ')} exited because of signal ${signal}.`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolvePromise({
|
||||
exitCode: code ?? 1,
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createReleaseDependencies(cwd: string): ReleaseDependencies {
|
||||
return {
|
||||
readText: (path) => readFile(path, 'utf8'),
|
||||
writeText: (path, content) => writeFile(path, content, 'utf8'),
|
||||
runGit: createGitRunner(cwd),
|
||||
log: console.log,
|
||||
};
|
||||
}
|
||||
|
||||
function parsePackageJson(packageJsonText: string): PackageJsonShape {
|
||||
try {
|
||||
return JSON.parse(packageJsonText) as PackageJsonShape;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Could not parse package.json: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseReleaseBump(value?: string): ReleaseBump {
|
||||
if (value === undefined) {
|
||||
return 'patch';
|
||||
}
|
||||
|
||||
if (value === 'patch' || value === 'minor' || value === 'major') {
|
||||
return value;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported release bump "${value}". Use patch, minor, or major.`);
|
||||
}
|
||||
|
||||
export function readPackageVersion(packageJsonText: string): string {
|
||||
const packageJson = parsePackageJson(packageJsonText);
|
||||
|
||||
if (typeof packageJson.version !== 'string') {
|
||||
throw new Error('package.json is missing a string version field.');
|
||||
}
|
||||
|
||||
return packageJson.version;
|
||||
}
|
||||
|
||||
function parseSemver(version: string): [number, number, number] {
|
||||
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(version);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`Unsupported version "${version}". Expected a simple x.y.z semantic version.`);
|
||||
}
|
||||
|
||||
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
||||
}
|
||||
|
||||
export function incrementVersion(version: string, bump: ReleaseBump): string {
|
||||
const [major, minor, patch] = parseSemver(version);
|
||||
|
||||
switch (bump) {
|
||||
case 'major':
|
||||
return `${major + 1}.0.0`;
|
||||
case 'minor':
|
||||
return `${major}.${minor + 1}.0`;
|
||||
case 'patch':
|
||||
return `${major}.${minor}.${patch + 1}`;
|
||||
}
|
||||
}
|
||||
|
||||
function detectNewline(packageJsonText: string): '\r\n' | '\n' {
|
||||
return packageJsonText.includes('\r\n') ? '\r\n' : '\n';
|
||||
}
|
||||
|
||||
export function updatePackageJsonVersion(packageJsonText: string, nextVersion: string): string {
|
||||
const newline = detectNewline(packageJsonText);
|
||||
const packageJson = parsePackageJson(packageJsonText);
|
||||
|
||||
if (typeof packageJson.version !== 'string') {
|
||||
throw new Error('package.json is missing a string version field.');
|
||||
}
|
||||
|
||||
packageJson.version = nextVersion;
|
||||
|
||||
return `${JSON.stringify(packageJson, null, 2).replace(/\n/g, newline)}${newline}`;
|
||||
}
|
||||
|
||||
function formatGitFailure(args: string[], result: GitCommandResult): string {
|
||||
const detail = result.stderr.trim() || result.stdout.trim();
|
||||
|
||||
if (detail.length > 0) {
|
||||
return `git ${args.join(' ')} failed: ${detail}`;
|
||||
}
|
||||
|
||||
return `git ${args.join(' ')} failed with exit code ${result.exitCode}.`;
|
||||
}
|
||||
|
||||
async function runGitOrThrow(runGit: GitRunner, args: string[], context: string): Promise<GitCommandResult> {
|
||||
const result = await runGit(args);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`${context} ${formatGitFailure(args, result)}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function ensureCleanWorktree(runGit: GitRunner): Promise<void> {
|
||||
const result = await runGitOrThrow(runGit, ['status', '--porcelain'], 'Could not inspect the current git worktree.');
|
||||
|
||||
if (result.stdout.trim().length > 0) {
|
||||
throw new Error('Release requires a clean git worktree. Commit, stash, or discard changes first.');
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveLocalBranch(runGit: GitRunner): Promise<string> {
|
||||
const result = await runGit(['symbolic-ref', '--quiet', '--short', 'HEAD']);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error('Release requires a checked-out branch. Detached HEAD is not supported.');
|
||||
}
|
||||
|
||||
const branch = result.stdout.trim();
|
||||
|
||||
if (branch.length === 0) {
|
||||
throw new Error('Release could not determine the current branch name.');
|
||||
}
|
||||
|
||||
return branch;
|
||||
}
|
||||
|
||||
function normalizeUpstreamBranch(mergeRef: string): string {
|
||||
const prefix = 'refs/heads/';
|
||||
|
||||
if (!mergeRef.startsWith(prefix)) {
|
||||
throw new Error(`Release expected an upstream branch ref, received "${mergeRef}".`);
|
||||
}
|
||||
|
||||
return mergeRef.slice(prefix.length);
|
||||
}
|
||||
|
||||
async function resolveUpstreamTarget(runGit: GitRunner, localBranch: string): Promise<UpstreamTarget> {
|
||||
const remoteResult = await runGit(['config', '--get', `branch.${localBranch}.remote`]);
|
||||
const mergeResult = await runGit(['config', '--get', `branch.${localBranch}.merge`]);
|
||||
|
||||
if (remoteResult.exitCode !== 0 || mergeResult.exitCode !== 0) {
|
||||
throw new Error(`Release requires an upstream branch for ${localBranch}. Configure tracking before running the release helper.`);
|
||||
}
|
||||
|
||||
const remote = remoteResult.stdout.trim();
|
||||
const mergeRef = mergeResult.stdout.trim();
|
||||
|
||||
if (remote.length === 0 || mergeRef.length === 0) {
|
||||
throw new Error(`Release requires an upstream branch for ${localBranch}. Configure tracking before running the release helper.`);
|
||||
}
|
||||
|
||||
return {
|
||||
remote,
|
||||
branch: normalizeUpstreamBranch(mergeRef),
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureTagDoesNotExist(runGit: GitRunner, tagName: string): Promise<void> {
|
||||
const result = await runGitOrThrow(runGit, ['tag', '--list', tagName], 'Could not inspect existing git tags.');
|
||||
|
||||
if (result.stdout.trim().length > 0) {
|
||||
throw new Error(`Tag ${tagName} already exists. Choose a different release version.`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runReleaseWorkflow(
|
||||
options: ReleaseWorkflowOptions,
|
||||
dependencies: ReleaseDependencies,
|
||||
): Promise<ReleaseResult> {
|
||||
const bump = parseReleaseBump(options.bumpArg);
|
||||
const packageJsonText = await dependencies.readText(options.packageJsonPath);
|
||||
const currentVersion = readPackageVersion(packageJsonText);
|
||||
const nextVersion = incrementVersion(currentVersion, bump);
|
||||
const tagName = `v${nextVersion}`;
|
||||
const packageJsonFile = relative(options.repositoryRoot, options.packageJsonPath);
|
||||
|
||||
await ensureCleanWorktree(dependencies.runGit);
|
||||
|
||||
const localBranch = await resolveLocalBranch(dependencies.runGit);
|
||||
const upstreamTarget = await resolveUpstreamTarget(dependencies.runGit, localBranch);
|
||||
|
||||
await ensureTagDoesNotExist(dependencies.runGit, tagName);
|
||||
|
||||
const updatedPackageJson = updatePackageJsonVersion(packageJsonText, nextVersion);
|
||||
const commitMessage = `chore: release ${tagName}`;
|
||||
const tagMessage = `Release ${tagName}`;
|
||||
|
||||
await dependencies.writeText(options.packageJsonPath, updatedPackageJson);
|
||||
await runGitOrThrow(dependencies.runGit, ['add', packageJsonFile], `Could not stage ${packageJsonFile}.`);
|
||||
await runGitOrThrow(dependencies.runGit, ['commit', '-m', commitMessage], 'Could not create the release commit.');
|
||||
await runGitOrThrow(dependencies.runGit, ['tag', '-a', tagName, '-m', tagMessage], 'Could not create the annotated release tag.');
|
||||
await runGitOrThrow(
|
||||
dependencies.runGit,
|
||||
['push', '--follow-tags', upstreamTarget.remote, `HEAD:${upstreamTarget.branch}`],
|
||||
'Could not push the release commit and tag.',
|
||||
);
|
||||
|
||||
dependencies.log(`Released ${tagName} from ${localBranch} to ${upstreamTarget.remote}/${upstreamTarget.branch}.`);
|
||||
|
||||
return {
|
||||
bump,
|
||||
currentVersion,
|
||||
nextVersion,
|
||||
tagName,
|
||||
localBranch,
|
||||
remote: upstreamTarget.remote,
|
||||
upstreamBranch: upstreamTarget.branch,
|
||||
};
|
||||
}
|
||||
|
||||
export async function main(argv: string[]): Promise<void> {
|
||||
await runReleaseWorkflow(
|
||||
{
|
||||
repositoryRoot,
|
||||
packageJsonPath,
|
||||
bumpArg: argv[0],
|
||||
},
|
||||
createReleaseDependencies(repositoryRoot),
|
||||
);
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
await main(process.argv.slice(2)).catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
export const productName = 'Aryx';
|
||||
export const macBundleIdentifier = 'com.davidkaya.aryx';
|
||||
|
||||
type SupportedPlatform = 'win32' | 'darwin' | 'linux';
|
||||
type SupportedArch = 'x64' | 'arm64';
|
||||
|
||||
export interface ReleaseTarget {
|
||||
readonly platform: SupportedPlatform;
|
||||
readonly arch: SupportedArch;
|
||||
readonly platformLabel: 'windows' | 'macos' | 'linux';
|
||||
readonly dotnetRuntime: `${string}-${SupportedArch}`;
|
||||
readonly outputDirectoryName: string;
|
||||
readonly archiveBaseName: string;
|
||||
readonly installerAssetName: string;
|
||||
readonly sidecarExecutableName: string;
|
||||
readonly packagedExecutableName?: string;
|
||||
readonly appBundleName?: string;
|
||||
}
|
||||
|
||||
function resolveSupportedArch(
|
||||
platform: SupportedPlatform,
|
||||
arch: NodeJS.Architecture,
|
||||
): SupportedArch {
|
||||
if (arch === 'x64' || arch === 'arm64') {
|
||||
return arch;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported architecture for ${platform}: ${arch}`);
|
||||
}
|
||||
|
||||
export function resolveReleaseTarget(
|
||||
platform: NodeJS.Platform,
|
||||
arch: NodeJS.Architecture,
|
||||
): ReleaseTarget {
|
||||
switch (platform) {
|
||||
case 'win32': {
|
||||
const supportedArch = resolveSupportedArch(platform, arch);
|
||||
const archiveBaseName = `${productName}-windows-${supportedArch}`;
|
||||
|
||||
return {
|
||||
platform,
|
||||
arch: supportedArch,
|
||||
platformLabel: 'windows',
|
||||
dotnetRuntime: `win-${supportedArch}`,
|
||||
outputDirectoryName: archiveBaseName,
|
||||
archiveBaseName,
|
||||
installerAssetName: `${archiveBaseName}-setup.exe`,
|
||||
sidecarExecutableName: 'Aryx.AgentHost.exe',
|
||||
packagedExecutableName: `${productName}.exe`,
|
||||
};
|
||||
}
|
||||
case 'darwin': {
|
||||
const supportedArch = resolveSupportedArch(platform, arch);
|
||||
const archiveBaseName = `${productName}-macos-${supportedArch}`;
|
||||
|
||||
return {
|
||||
platform,
|
||||
arch: supportedArch,
|
||||
platformLabel: 'macos',
|
||||
dotnetRuntime: `osx-${supportedArch}`,
|
||||
outputDirectoryName: archiveBaseName,
|
||||
archiveBaseName,
|
||||
installerAssetName: `${archiveBaseName}.dmg`,
|
||||
sidecarExecutableName: 'Aryx.AgentHost',
|
||||
appBundleName: `${productName}.app`,
|
||||
};
|
||||
}
|
||||
case 'linux': {
|
||||
const supportedArch = resolveSupportedArch(platform, arch);
|
||||
const archiveBaseName = `${productName}-linux-${supportedArch}`;
|
||||
|
||||
return {
|
||||
platform,
|
||||
arch: supportedArch,
|
||||
platformLabel: 'linux',
|
||||
dotnetRuntime: `linux-${supportedArch}`,
|
||||
outputDirectoryName: archiveBaseName,
|
||||
archiveBaseName,
|
||||
installerAssetName: `aryx-linux-${supportedArch}.deb`,
|
||||
sidecarExecutableName: 'Aryx.AgentHost',
|
||||
packagedExecutableName: productName,
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported release platform: ${platform}`);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
BIN
Binary file not shown.
@@ -2,17 +2,19 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="GitHub.Copilot.SDK" Version="0.2.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI" Version="1.0.0-rc4" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.GitHub.Copilot" Version="1.0.0-preview.260311.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.0.0-rc4" />
|
||||
<PackageReference Include="GitHub.Copilot.SDK" Version="0.2.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI" Version="1.1.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.GitHub.Copilot" Version="1.1.0-preview.260410.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.1.0" />
|
||||
<PackageReference Include="OpenTelemetry" Version="1.13.1" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.13.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Aryx.AgentHost.Contracts;
|
||||
|
||||
internal static class HookTypeNames
|
||||
{
|
||||
public const string SessionStart = "sessionStart";
|
||||
public const string SessionEnd = "sessionEnd";
|
||||
public const string UserPromptSubmitted = "userPromptSubmitted";
|
||||
public const string PreToolUse = "preToolUse";
|
||||
public const string PostToolUse = "postToolUse";
|
||||
public const string ErrorOccurred = "errorOccurred";
|
||||
}
|
||||
|
||||
internal sealed class HookConfigFile
|
||||
{
|
||||
public int Version { get; init; }
|
||||
public HookConfigHooks Hooks { get; init; } = new();
|
||||
}
|
||||
|
||||
internal sealed class HookConfigHooks
|
||||
{
|
||||
public IReadOnlyList<HookCommandDefinition>? SessionStart { get; init; }
|
||||
public IReadOnlyList<HookCommandDefinition>? SessionEnd { get; init; }
|
||||
public IReadOnlyList<HookCommandDefinition>? UserPromptSubmitted { get; init; }
|
||||
public IReadOnlyList<HookCommandDefinition>? PreToolUse { get; init; }
|
||||
public IReadOnlyList<HookCommandDefinition>? PostToolUse { get; init; }
|
||||
public IReadOnlyList<HookCommandDefinition>? ErrorOccurred { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class HookCommandDefinition
|
||||
{
|
||||
public string Type { get; init; } = string.Empty;
|
||||
public string? Bash { get; init; }
|
||||
|
||||
[JsonPropertyName("powershell")]
|
||||
public string? PowerShell { get; init; }
|
||||
|
||||
public string? Cwd { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Env { get; init; }
|
||||
public int? TimeoutSec { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class ResolvedHookSet
|
||||
{
|
||||
public static ResolvedHookSet Empty { get; } = new();
|
||||
|
||||
public IReadOnlyList<HookCommandDefinition> SessionStart { get; init; } = [];
|
||||
public IReadOnlyList<HookCommandDefinition> SessionEnd { get; init; } = [];
|
||||
public IReadOnlyList<HookCommandDefinition> UserPromptSubmitted { get; init; } = [];
|
||||
public IReadOnlyList<HookCommandDefinition> PreToolUse { get; init; } = [];
|
||||
public IReadOnlyList<HookCommandDefinition> PostToolUse { get; init; } = [];
|
||||
public IReadOnlyList<HookCommandDefinition> ErrorOccurred { get; init; } = [];
|
||||
|
||||
public bool IsEmpty =>
|
||||
SessionStart.Count == 0
|
||||
&& SessionEnd.Count == 0
|
||||
&& UserPromptSubmitted.Count == 0
|
||||
&& PreToolUse.Count == 0
|
||||
&& PostToolUse.Count == 0
|
||||
&& ErrorOccurred.Count == 0;
|
||||
}
|
||||
@@ -1,57 +1,156 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Aryx.AgentHost.Contracts;
|
||||
|
||||
public sealed class PatternAgentDefinitionDto
|
||||
public sealed class WorkflowAgentCopilotConfigDto
|
||||
{
|
||||
public IReadOnlyList<RunTurnCustomAgentConfigDto> CustomAgents { get; init; } = [];
|
||||
public string? Agent { get; init; }
|
||||
public IReadOnlyList<string> SkillDirectories { get; init; } = [];
|
||||
public IReadOnlyList<string> DisabledSkills { get; init; } = [];
|
||||
public RunTurnInfiniteSessionsConfigDto? InfiniteSessions { get; init; }
|
||||
}
|
||||
|
||||
public sealed class WorkflowPositionDto
|
||||
{
|
||||
public double X { get; init; }
|
||||
public double Y { get; init; }
|
||||
}
|
||||
|
||||
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 PatternGraphPositionDto
|
||||
{
|
||||
public double X { get; init; }
|
||||
public double Y { get; init; }
|
||||
}
|
||||
|
||||
public sealed class PatternGraphNodeDto
|
||||
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;
|
||||
}
|
||||
@@ -75,13 +174,26 @@ 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; } = [];
|
||||
}
|
||||
|
||||
public sealed class PatternValidationIssueDto
|
||||
public sealed class ChatMessageAttachmentDto
|
||||
{
|
||||
public string Type { get; init; } = string.Empty;
|
||||
public string? Path { get; init; }
|
||||
public string? Data { get; init; }
|
||||
public string? MimeType { get; init; }
|
||||
public string? DisplayName { get; init; }
|
||||
}
|
||||
|
||||
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
|
||||
@@ -151,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
|
||||
@@ -161,9 +274,27 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string ProjectPath { get; init; } = string.Empty;
|
||||
public string WorkspaceKind { get; init; } = "project";
|
||||
public PatternDefinitionDto Pattern { get; init; } = new();
|
||||
public string Mode { get; init; } = "interactive";
|
||||
public string MessageMode { get; init; } = "enqueue";
|
||||
public string? ProjectInstructions { get; init; }
|
||||
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
|
||||
@@ -175,8 +306,34 @@ public sealed class ResolveApprovalCommandDto : SidecarCommandEnvelope
|
||||
{
|
||||
public string ApprovalId { get; init; } = string.Empty;
|
||||
public string Decision { get; init; } = string.Empty;
|
||||
public bool AlwaysApprove { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ResolveUserInputCommandDto : SidecarCommandEnvelope
|
||||
{
|
||||
public string UserInputId { get; init; } = string.Empty;
|
||||
public string Answer { get; init; } = string.Empty;
|
||||
public bool WasFreeform { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ListSessionsCommandDto : SidecarCommandEnvelope
|
||||
{
|
||||
public CopilotSessionListFilterDto? Filter { get; init; }
|
||||
}
|
||||
|
||||
public sealed class DeleteSessionCommandDto : SidecarCommandEnvelope
|
||||
{
|
||||
public string? SessionId { get; init; }
|
||||
public string? CopilotSessionId { get; init; }
|
||||
}
|
||||
|
||||
public sealed class DisconnectSessionCommandDto : SidecarCommandEnvelope
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class GetQuotaCommandDto : SidecarCommandEnvelope;
|
||||
|
||||
public sealed class RunTurnToolingConfigDto
|
||||
{
|
||||
public IReadOnlyList<RunTurnMcpServerConfigDto> McpServers { get; init; } = [];
|
||||
@@ -208,6 +365,48 @@ public sealed class RunTurnLspProfileConfigDto
|
||||
public IReadOnlyList<string> FileExtensions { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class RunTurnCustomAgentConfigDto
|
||||
{
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string? DisplayName { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public IReadOnlyList<string>? Tools { get; init; }
|
||||
public string Prompt { get; init; } = string.Empty;
|
||||
public IReadOnlyList<RunTurnMcpServerConfigDto> McpServers { get; init; } = [];
|
||||
public bool? Infer { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RunTurnInfiniteSessionsConfigDto
|
||||
{
|
||||
public bool? Enabled { get; init; }
|
||||
public double? BackgroundCompactionThreshold { get; init; }
|
||||
public double? BufferExhaustionThreshold { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CopilotSessionListFilterDto
|
||||
{
|
||||
public string? Cwd { get; init; }
|
||||
public string? GitRoot { get; init; }
|
||||
public string? Repository { get; init; }
|
||||
public string? Branch { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CopilotSessionInfoDto
|
||||
{
|
||||
public string CopilotSessionId { get; init; } = string.Empty;
|
||||
public bool ManagedByAryx { get; init; }
|
||||
public string? SessionId { get; init; }
|
||||
public string? AgentId { get; init; }
|
||||
public string StartTime { get; init; } = string.Empty;
|
||||
public string ModifiedTime { get; init; } = string.Empty;
|
||||
public string? Summary { get; init; }
|
||||
public bool IsRemote { get; init; }
|
||||
public string? Cwd { get; init; }
|
||||
public string? GitRoot { get; init; }
|
||||
public string? Repository { get; init; }
|
||||
public string? Branch { get; init; }
|
||||
}
|
||||
|
||||
public abstract class SidecarEventDto
|
||||
{
|
||||
public string Type { get; init; } = string.Empty;
|
||||
@@ -219,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
|
||||
@@ -240,15 +439,234 @@ 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; }
|
||||
}
|
||||
|
||||
public sealed class SubagentEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string EventKind { get; init; } = string.Empty;
|
||||
public string? AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
public string? ToolCallId { get; init; }
|
||||
public string? CustomAgentName { get; init; }
|
||||
public string? CustomAgentDisplayName { get; init; }
|
||||
public string? CustomAgentDescription { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public string? Model { get; init; }
|
||||
public double? TotalToolCalls { get; init; }
|
||||
public double? TotalTokens { get; init; }
|
||||
public double? DurationMs { get; init; }
|
||||
public IReadOnlyList<string>? Tools { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SkillInvokedEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string? AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
public string SkillName { get; init; } = string.Empty;
|
||||
public string Path { get; init; } = string.Empty;
|
||||
public string Content { get; init; } = string.Empty;
|
||||
public IReadOnlyList<string>? AllowedTools { get; init; }
|
||||
public string? PluginName { get; init; }
|
||||
public string? PluginVersion { get; init; }
|
||||
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;
|
||||
public string? AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
public string HookInvocationId { get; init; } = string.Empty;
|
||||
public string HookType { get; init; } = string.Empty;
|
||||
public string Phase { get; init; } = string.Empty;
|
||||
public bool? Success { get; init; }
|
||||
public object? Input { get; init; }
|
||||
public object? Output { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
public sealed class QuotaSnapshotDto
|
||||
{
|
||||
public double EntitlementRequests { get; init; }
|
||||
public double UsedRequests { get; init; }
|
||||
public double RemainingPercentage { get; init; }
|
||||
public double Overage { get; init; }
|
||||
public bool OverageAllowedWithExhaustedQuota { get; init; }
|
||||
public string? ResetDate { get; init; }
|
||||
}
|
||||
|
||||
public sealed class AccountQuotaResultEventDto : SidecarEventDto
|
||||
{
|
||||
public Dictionary<string, QuotaSnapshotDto> QuotaSnapshots { get; init; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public sealed class AssistantUsageEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string? AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
public string Model { get; init; } = string.Empty;
|
||||
public double? InputTokens { get; init; }
|
||||
public double? OutputTokens { get; init; }
|
||||
public double? CacheReadTokens { get; init; }
|
||||
public double? CacheWriteTokens { get; init; }
|
||||
public double? Cost { get; init; }
|
||||
public double? Duration { get; init; }
|
||||
public double? TotalNanoAiu { get; init; }
|
||||
public Dictionary<string, QuotaSnapshotDto>? QuotaSnapshots { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SessionUsageEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string? AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
public double TokenLimit { get; init; }
|
||||
public double CurrentTokens { get; init; }
|
||||
public double MessagesLength { get; init; }
|
||||
public double? SystemTokens { get; init; }
|
||||
public double? ConversationTokens { get; init; }
|
||||
public double? ToolDefinitionsTokens { get; init; }
|
||||
public bool? IsInitial { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SessionCompactionEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string? AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
public string Phase { get; init; } = string.Empty;
|
||||
public bool? Success { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public double? SystemTokens { get; init; }
|
||||
public double? ConversationTokens { get; init; }
|
||||
public double? ToolDefinitionsTokens { get; init; }
|
||||
public double? PreCompactionTokens { get; init; }
|
||||
public double? PostCompactionTokens { get; init; }
|
||||
public double? PreCompactionMessagesLength { get; init; }
|
||||
public double? MessagesRemoved { get; init; }
|
||||
public double? TokensRemoved { get; init; }
|
||||
public string? SummaryContent { get; init; }
|
||||
public double? CheckpointNumber { get; init; }
|
||||
public string? CheckpointPath { get; init; }
|
||||
}
|
||||
|
||||
public sealed class PendingMessagesModifiedEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string? AgentId { get; init; }
|
||||
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; } = [];
|
||||
}
|
||||
|
||||
public sealed class SessionsDeletedEventDto : SidecarEventDto
|
||||
{
|
||||
public string? SessionId { get; init; }
|
||||
public IReadOnlyList<CopilotSessionInfoDto> Sessions { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class SessionDisconnectedEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public IReadOnlyList<string> CancelledRequestIds { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class PermissionDetailDto
|
||||
{
|
||||
public string Kind { get; init; } = string.Empty;
|
||||
public string? Intention { get; init; }
|
||||
public string? Command { get; init; }
|
||||
public string? Warning { get; init; }
|
||||
public IReadOnlyList<string>? PossiblePaths { get; init; }
|
||||
public IReadOnlyList<string>? PossibleUrls { get; init; }
|
||||
public bool? HasWriteFileRedirection { get; init; }
|
||||
public string? FileName { get; init; }
|
||||
public string? Diff { get; init; }
|
||||
public string? NewFileContents { get; init; }
|
||||
public string? Path { get; init; }
|
||||
public string? ServerName { get; init; }
|
||||
public string? ToolTitle { get; init; }
|
||||
public object? Args { get; init; }
|
||||
public bool? ReadOnly { get; init; }
|
||||
public string? Url { get; init; }
|
||||
public string? Subject { get; init; }
|
||||
public string? Fact { get; init; }
|
||||
public string? Citations { get; init; }
|
||||
public string? ToolDescription { get; init; }
|
||||
public string? HookMessage { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ToolCallFileChangeDto
|
||||
{
|
||||
public string Path { get; init; } = string.Empty;
|
||||
public string? Diff { get; init; }
|
||||
public string? NewFileContents { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ApprovalRequestedEventDto : SidecarEventDto
|
||||
@@ -262,6 +680,54 @@ public sealed class ApprovalRequestedEventDto : SidecarEventDto
|
||||
public string? PermissionKind { get; init; }
|
||||
public string Title { get; init; } = string.Empty;
|
||||
public string? Detail { get; init; }
|
||||
public PermissionDetailDto? PermissionDetail { get; init; }
|
||||
}
|
||||
|
||||
public sealed class UserInputRequestedEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string UserInputId { get; init; } = string.Empty;
|
||||
public string? AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
public string Question { get; init; } = string.Empty;
|
||||
public IReadOnlyList<string>? Choices { get; init; }
|
||||
public bool? AllowFreeform { get; init; }
|
||||
}
|
||||
|
||||
public sealed class McpOauthStaticClientConfigDto
|
||||
{
|
||||
public string ClientId { get; init; } = string.Empty;
|
||||
public bool? PublicClient { get; init; }
|
||||
}
|
||||
|
||||
public sealed class McpOauthRequiredEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string OauthRequestId { get; init; } = string.Empty;
|
||||
public string? AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
public string ServerName { get; init; } = string.Empty;
|
||||
public string ServerUrl { get; init; } = string.Empty;
|
||||
public McpOauthStaticClientConfigDto? StaticClientConfig { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ExitPlanModeRequestedEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string ExitPlanId { get; init; } = string.Empty;
|
||||
public string? AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
public string Summary { get; init; } = string.Empty;
|
||||
public string PlanContent { get; init; } = string.Empty;
|
||||
public IReadOnlyList<string>? Actions { get; init; }
|
||||
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
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
namespace Aryx.AgentHost.Contracts;
|
||||
|
||||
internal abstract record ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderAssistantMessageDeltaEvent(
|
||||
string MessageId,
|
||||
string? DeltaContent) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderAssistantMessageEvent(
|
||||
string MessageId,
|
||||
string? Content,
|
||||
bool HasToolRequests) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderToolExecutionStartEvent(
|
||||
string ToolCallId,
|
||||
string ToolName,
|
||||
IReadOnlyDictionary<string, object?>? ToolArguments) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderToolExecutionProgressEvent(
|
||||
string ToolCallId,
|
||||
string? ProgressMessage) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderToolExecutionPartialResultEvent(
|
||||
string ToolCallId,
|
||||
string? PartialOutput) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderToolExecutionCompleteEvent(
|
||||
string ToolCallId,
|
||||
bool Success,
|
||||
string? ResultContent,
|
||||
string? DetailedResultContent,
|
||||
string? Error) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderAssistantIntentEvent(string? Intent) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderAssistantReasoningDeltaEvent(
|
||||
string? ReasoningId,
|
||||
string? DeltaContent) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderAssistantReasoningEvent(
|
||||
string? ReasoningId,
|
||||
string? Content) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderAssistantTurnStartEvent(string? TurnId) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderAssistantTurnEndEvent(string? TurnId) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderSubagentStartedEvent(
|
||||
string? ToolCallId,
|
||||
string? AgentName,
|
||||
string? AgentDisplayName,
|
||||
string? AgentDescription) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderSubagentCompletedEvent(
|
||||
string? ToolCallId,
|
||||
string? AgentName,
|
||||
string? AgentDisplayName) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderSubagentFailedEvent(
|
||||
string? ToolCallId,
|
||||
string? AgentName,
|
||||
string? AgentDisplayName,
|
||||
string? Error) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderSubagentSelectedEvent(
|
||||
string? AgentName,
|
||||
string? AgentDisplayName,
|
||||
IReadOnlyList<string>? Tools) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderSubagentDeselectedEvent() : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderSkillInvokedEvent(
|
||||
string SkillName,
|
||||
string Path,
|
||||
string Content,
|
||||
IReadOnlyList<string>? AllowedTools,
|
||||
string? PluginName,
|
||||
string? PluginVersion) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderHookStartEvent(
|
||||
string HookInvocationId,
|
||||
string HookType,
|
||||
object? Input) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderHookEndEvent(
|
||||
string HookInvocationId,
|
||||
string HookType,
|
||||
bool? Success,
|
||||
object? Output,
|
||||
string? Error) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderAssistantUsageEvent(
|
||||
string Model,
|
||||
double? InputTokens,
|
||||
double? OutputTokens,
|
||||
double? CacheReadTokens,
|
||||
double? CacheWriteTokens,
|
||||
double? Cost,
|
||||
double? Duration,
|
||||
double? TotalNanoAiu,
|
||||
Dictionary<string, QuotaSnapshotDto>? QuotaSnapshots) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderSessionUsageEvent(
|
||||
double TokenLimit,
|
||||
double CurrentTokens,
|
||||
double MessagesLength,
|
||||
double? SystemTokens,
|
||||
double? ConversationTokens,
|
||||
double? ToolDefinitionsTokens,
|
||||
bool? IsInitial) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderSessionCompactionStartEvent(
|
||||
double? SystemTokens,
|
||||
double? ConversationTokens,
|
||||
double? ToolDefinitionsTokens) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderSessionCompactionCompleteEvent(
|
||||
bool? Success,
|
||||
string? Error,
|
||||
double? SystemTokens,
|
||||
double? ConversationTokens,
|
||||
double? ToolDefinitionsTokens,
|
||||
double? PreCompactionTokens,
|
||||
double? PostCompactionTokens,
|
||||
double? PreCompactionMessagesLength,
|
||||
double? MessagesRemoved,
|
||||
double? TokensRemoved,
|
||||
string? SummaryContent,
|
||||
double? CheckpointNumber,
|
||||
string? CheckpointPath) : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderPendingMessagesModifiedEvent() : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderMcpOauthRequiredEvent() : ProviderSessionEvent;
|
||||
|
||||
internal sealed record ProviderExitPlanModeRequestedEvent() : ProviderSessionEvent;
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace Aryx.AgentHost.Contracts;
|
||||
|
||||
internal sealed record ProviderTurnStreamCapabilities
|
||||
{
|
||||
public static ProviderTurnStreamCapabilities None { get; } = new();
|
||||
|
||||
public bool SupportsIntent { get; init; }
|
||||
|
||||
public bool SupportsReasoningDelta { get; init; }
|
||||
|
||||
public bool SupportsReasoningBlock { get; init; }
|
||||
|
||||
public bool SupportsToolExecutionProgress { get; init; }
|
||||
|
||||
public bool SupportsToolExecutionPartialResult { get; init; }
|
||||
|
||||
public bool SupportsToolExecutionCompletion { get; init; }
|
||||
|
||||
public bool SupportsSubagentLifecycle { get; init; }
|
||||
|
||||
public bool SupportsHookLifecycle { get; init; }
|
||||
|
||||
public bool SupportsSessionCompaction { get; init; }
|
||||
|
||||
public bool SupportsPendingMessagesMutation { get; init; }
|
||||
|
||||
public bool SupportsSessionTurnBoundaries { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace Aryx.AgentHost.Contracts;
|
||||
|
||||
internal enum ProviderToolExecutionStatus
|
||||
{
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
internal sealed record ProviderToolExecutionSnapshot
|
||||
{
|
||||
public string ToolCallId { get; init; } = string.Empty;
|
||||
|
||||
public string? ToolName { get; init; }
|
||||
|
||||
public IReadOnlyDictionary<string, object?>? ToolArguments { get; init; }
|
||||
|
||||
public ProviderToolExecutionStatus Status { get; init; }
|
||||
|
||||
public string? LatestProgressMessage { get; init; }
|
||||
|
||||
public string PartialOutput { get; init; } = string.Empty;
|
||||
|
||||
public string? ResultContent { get; init; }
|
||||
|
||||
public string? DetailedResultContent { get; init; }
|
||||
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record ProviderReasoningSnapshot
|
||||
{
|
||||
public string ReasoningId { get; init; } = string.Empty;
|
||||
|
||||
public string Content { get; init; } = string.Empty;
|
||||
|
||||
public bool IsComplete { get; init; }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Aryx.AgentHost.Services;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
if (!args.Contains("--stdio", StringComparer.Ordinal))
|
||||
{
|
||||
@@ -6,5 +7,7 @@ if (!args.Contains("--stdio", StringComparer.Ordinal))
|
||||
return;
|
||||
}
|
||||
|
||||
using TracerProvider? tracerProvider = OpenTelemetrySetup.CreateTracerProviderFromEnvironment();
|
||||
|
||||
SidecarProtocolHost host = new();
|
||||
await host.RunAsync(Console.In, Console.Out, CancellationToken.None);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal static class AgentHostOptionsFactory
|
||||
{
|
||||
public static AIAgentHostOptions CreateDefault()
|
||||
{
|
||||
return new AIAgentHostOptions
|
||||
{
|
||||
EmitAgentUpdateEvents = null,
|
||||
EmitAgentResponseEvents = false,
|
||||
InterceptUserInputRequests = false,
|
||||
InterceptUnterminatedFunctionCalls = false,
|
||||
ReassignOtherAgentsAsUsers = true,
|
||||
ForwardIncomingMessages = true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,37 +3,111 @@ using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal readonly record struct AgentIdentity(string AgentId, string AgentName);
|
||||
internal readonly record struct SubworkflowContext(string SubworkflowNodeId, string SubworkflowName);
|
||||
|
||||
internal readonly record struct AgentIdentity(
|
||||
string AgentId,
|
||||
string AgentName,
|
||||
SubworkflowContext? Subworkflow = null);
|
||||
|
||||
internal static class AgentIdentityResolver
|
||||
{
|
||||
private const string GenericAssistantIdentifier = "assistant";
|
||||
|
||||
public static bool TryResolveKnownAgentIdentity(
|
||||
PatternDefinitionDto pattern,
|
||||
WorkflowDefinitionDto workflow,
|
||||
string? agentIdentifier,
|
||||
out AgentIdentity agent)
|
||||
{
|
||||
return TryResolveKnownAgentIdentity(
|
||||
workflow,
|
||||
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(null),
|
||||
agentIdentifier,
|
||||
agentSubworkflowIndex: null,
|
||||
out agent);
|
||||
}
|
||||
|
||||
public static bool TryResolveKnownAgentIdentity(
|
||||
WorkflowDefinitionDto workflow,
|
||||
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary,
|
||||
string? agentIdentifier,
|
||||
out AgentIdentity agent)
|
||||
{
|
||||
return TryResolveKnownAgentIdentity(
|
||||
workflow,
|
||||
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(workflowLibrary),
|
||||
agentIdentifier,
|
||||
agentSubworkflowIndex: null,
|
||||
out agent);
|
||||
}
|
||||
|
||||
internal static bool TryResolveKnownAgentIdentity(
|
||||
WorkflowDefinitionDto workflow,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
|
||||
string? agentIdentifier,
|
||||
IReadOnlyDictionary<string, SubworkflowContext>? agentSubworkflowIndex,
|
||||
out AgentIdentity agent)
|
||||
{
|
||||
agent = default;
|
||||
|
||||
PatternAgentDefinitionDto? match = FindKnownAgent(pattern, agentIdentifier)
|
||||
?? ResolveSingleAgentAssistantAlias(pattern, agentIdentifier);
|
||||
if (match is null)
|
||||
WorkflowNodeDto? shallowMatch = FindKnownAgent(workflow.GetAgentNodes(), agentIdentifier);
|
||||
if (shallowMatch is not null)
|
||||
{
|
||||
agent = ToAgentIdentity(shallowMatch);
|
||||
return true;
|
||||
}
|
||||
|
||||
WorkflowNodeDto? deepMatch = FindKnownAgent(workflow.GetAllAgentNodes(workflowLibrary), agentIdentifier);
|
||||
if (deepMatch is not null)
|
||||
{
|
||||
IReadOnlyDictionary<string, SubworkflowContext> subworkflowIndex = agentSubworkflowIndex
|
||||
?? BuildAgentSubworkflowIndex(workflow, workflowLibrary);
|
||||
agent = ToAgentIdentity(deepMatch, subworkflowIndex);
|
||||
return true;
|
||||
}
|
||||
|
||||
WorkflowNodeDto? aliasMatch = ResolveSingleAgentAssistantAlias(workflow, workflowLibrary, agentIdentifier);
|
||||
if (aliasMatch is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
agent = ToAgentIdentity(match);
|
||||
if (workflow.GetAgentNodes().Contains(aliasMatch))
|
||||
{
|
||||
agent = ToAgentIdentity(aliasMatch);
|
||||
return true;
|
||||
}
|
||||
|
||||
IReadOnlyDictionary<string, SubworkflowContext> aliasSubworkflowIndex = agentSubworkflowIndex
|
||||
?? BuildAgentSubworkflowIndex(workflow, workflowLibrary);
|
||||
agent = ToAgentIdentity(aliasMatch, aliasSubworkflowIndex);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryResolveObservedAgentIdentity(
|
||||
PatternDefinitionDto pattern,
|
||||
WorkflowDefinitionDto workflow,
|
||||
string? agentIdentifier,
|
||||
AgentIdentity? fallbackAgent,
|
||||
out AgentIdentity agent)
|
||||
{
|
||||
if (TryResolveKnownAgentIdentity(pattern, agentIdentifier, out agent))
|
||||
return TryResolveObservedAgentIdentity(
|
||||
workflow,
|
||||
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(null),
|
||||
agentIdentifier,
|
||||
fallbackAgent,
|
||||
agentSubworkflowIndex: null,
|
||||
out agent);
|
||||
}
|
||||
|
||||
internal static bool TryResolveObservedAgentIdentity(
|
||||
WorkflowDefinitionDto workflow,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
|
||||
string? agentIdentifier,
|
||||
AgentIdentity? fallbackAgent,
|
||||
IReadOnlyDictionary<string, SubworkflowContext>? agentSubworkflowIndex,
|
||||
out AgentIdentity agent)
|
||||
{
|
||||
if (TryResolveKnownAgentIdentity(workflow, workflowLibrary, agentIdentifier, agentSubworkflowIndex, out agent))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -49,30 +123,63 @@ internal static class AgentIdentityResolver
|
||||
}
|
||||
|
||||
public static AgentIdentity ResolveAgentIdentity(
|
||||
PatternDefinitionDto pattern,
|
||||
WorkflowDefinitionDto workflow,
|
||||
string? agentId,
|
||||
string? agentName)
|
||||
{
|
||||
PatternAgentDefinitionDto? match = FindKnownAgent(pattern, agentId)
|
||||
?? FindKnownAgent(pattern, agentName)
|
||||
?? ResolveSingleAgentAssistantAlias(pattern, agentId, agentName);
|
||||
return ResolveAgentIdentity(
|
||||
workflow,
|
||||
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(null),
|
||||
agentId,
|
||||
agentName,
|
||||
agentSubworkflowIndex: null);
|
||||
}
|
||||
|
||||
return match is not null
|
||||
? ToAgentIdentity(match)
|
||||
: CreateFallbackIdentity(agentId, agentName);
|
||||
public static AgentIdentity ResolveAgentIdentity(
|
||||
WorkflowDefinitionDto workflow,
|
||||
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary,
|
||||
string? agentId,
|
||||
string? agentName)
|
||||
{
|
||||
return ResolveAgentIdentity(
|
||||
workflow,
|
||||
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(workflowLibrary),
|
||||
agentId,
|
||||
agentName,
|
||||
agentSubworkflowIndex: null);
|
||||
}
|
||||
|
||||
internal static AgentIdentity ResolveAgentIdentity(
|
||||
WorkflowDefinitionDto workflow,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
|
||||
string? agentId,
|
||||
string? agentName,
|
||||
IReadOnlyDictionary<string, SubworkflowContext>? agentSubworkflowIndex)
|
||||
{
|
||||
if (TryResolveKnownAgentIdentity(workflow, workflowLibrary, agentId, agentSubworkflowIndex, out AgentIdentity resolvedById))
|
||||
{
|
||||
return resolvedById;
|
||||
}
|
||||
|
||||
if (TryResolveKnownAgentIdentity(workflow, workflowLibrary, agentName, agentSubworkflowIndex, out AgentIdentity resolvedByName))
|
||||
{
|
||||
return resolvedByName;
|
||||
}
|
||||
|
||||
return CreateFallbackIdentity(agentId, agentName, agentSubworkflowIndex);
|
||||
}
|
||||
|
||||
public static string ResolveDisplayAuthorName(
|
||||
PatternDefinitionDto pattern,
|
||||
WorkflowDefinitionDto workflow,
|
||||
string? primaryIdentifier,
|
||||
string? fallbackIdentifier = null)
|
||||
{
|
||||
if (TryResolveKnownAgentIdentity(pattern, primaryIdentifier, out AgentIdentity primaryAgent))
|
||||
if (TryResolveKnownAgentIdentity(workflow, primaryIdentifier, out AgentIdentity primaryAgent))
|
||||
{
|
||||
return primaryAgent.AgentName;
|
||||
}
|
||||
|
||||
if (TryResolveKnownAgentIdentity(pattern, fallbackIdentifier, out AgentIdentity fallbackAgent))
|
||||
if (TryResolveKnownAgentIdentity(workflow, fallbackIdentifier, out AgentIdentity fallbackAgent))
|
||||
{
|
||||
return fallbackAgent.AgentName;
|
||||
}
|
||||
@@ -90,6 +197,53 @@ internal static class AgentIdentityResolver
|
||||
return GenericAssistantIdentifier;
|
||||
}
|
||||
|
||||
public static IReadOnlyDictionary<string, SubworkflowContext> BuildAgentSubworkflowIndex(
|
||||
WorkflowDefinitionDto workflow,
|
||||
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
|
||||
{
|
||||
return BuildAgentSubworkflowIndex(
|
||||
workflow,
|
||||
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(workflowLibrary));
|
||||
}
|
||||
|
||||
internal static IReadOnlyDictionary<string, SubworkflowContext> BuildAgentSubworkflowIndex(
|
||||
WorkflowDefinitionDto workflow,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflow);
|
||||
ArgumentNullException.ThrowIfNull(workflowLibrary);
|
||||
|
||||
Dictionary<string, SubworkflowContext> index = new(StringComparer.Ordinal);
|
||||
CollectAgentSubworkflowContexts(
|
||||
workflow,
|
||||
workflowLibrary,
|
||||
currentSubworkflow: null,
|
||||
index,
|
||||
new HashSet<string>(StringComparer.Ordinal),
|
||||
new HashSet<WorkflowDefinitionDto>(ReferenceEqualityComparer.Instance));
|
||||
return index;
|
||||
}
|
||||
|
||||
internal static bool TryResolveSubworkflowContext(
|
||||
WorkflowDefinitionDto workflow,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
|
||||
string? subworkflowNodeId,
|
||||
out SubworkflowContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflow);
|
||||
ArgumentNullException.ThrowIfNull(workflowLibrary);
|
||||
|
||||
context = default;
|
||||
WorkflowNodeDto? node = workflow.FindSubWorkflowNode(subworkflowNodeId, workflowLibrary);
|
||||
if (node is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
context = CreateSubworkflowContext(node, workflowLibrary);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static bool IsGenericAssistantIdentifier(string? candidate)
|
||||
{
|
||||
return string.Equals(
|
||||
@@ -98,55 +252,145 @@ internal static class AgentIdentityResolver
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static PatternAgentDefinitionDto? ResolveSingleAgentAssistantAlias(
|
||||
PatternDefinitionDto pattern,
|
||||
private static WorkflowNodeDto? ResolveSingleAgentAssistantAlias(
|
||||
WorkflowDefinitionDto workflow,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
|
||||
params string?[] agentIdentifiers)
|
||||
{
|
||||
return pattern.Agents.Count == 1 && agentIdentifiers.Any(IsGenericAssistantIdentifier)
|
||||
? pattern.Agents[0]
|
||||
: null;
|
||||
if (!agentIdentifiers.Any(IsGenericAssistantIdentifier))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IReadOnlyList<WorkflowNodeDto> topLevelAgents = workflow.GetAgentNodes();
|
||||
if (topLevelAgents.Count == 1)
|
||||
{
|
||||
return topLevelAgents[0];
|
||||
}
|
||||
|
||||
IReadOnlyList<WorkflowNodeDto> allAgents = workflow.GetAllAgentNodes(workflowLibrary);
|
||||
return allAgents.Count == 1 ? allAgents[0] : null;
|
||||
}
|
||||
|
||||
private static PatternAgentDefinitionDto? FindKnownAgent(PatternDefinitionDto pattern, string? candidate)
|
||||
private static WorkflowNodeDto? FindKnownAgent(
|
||||
IEnumerable<WorkflowNodeDto> agents,
|
||||
string? candidate)
|
||||
{
|
||||
return pattern.Agents.FirstOrDefault(agent => MatchesAgent(agent, candidate));
|
||||
return agents.FirstOrDefault(agent => MatchesAgent(agent, candidate));
|
||||
}
|
||||
|
||||
private static AgentIdentity ToAgentIdentity(PatternAgentDefinitionDto agent)
|
||||
private static AgentIdentity ToAgentIdentity(WorkflowNodeDto agent)
|
||||
=> new(agent.GetAgentId(), agent.GetAgentName());
|
||||
|
||||
private static AgentIdentity ToAgentIdentity(
|
||||
WorkflowNodeDto agent,
|
||||
IReadOnlyDictionary<string, SubworkflowContext> agentSubworkflowIndex)
|
||||
{
|
||||
return new AgentIdentity(
|
||||
agent.Id,
|
||||
string.IsNullOrWhiteSpace(agent.Name) ? agent.Id : agent.Name);
|
||||
string agentId = agent.GetAgentId();
|
||||
return agentSubworkflowIndex.TryGetValue(agentId, out SubworkflowContext subworkflow)
|
||||
? new AgentIdentity(agentId, agent.GetAgentName(), subworkflow)
|
||||
: new AgentIdentity(agentId, agent.GetAgentName());
|
||||
}
|
||||
|
||||
private static AgentIdentity CreateFallbackIdentity(string? agentId, string? agentName)
|
||||
private static AgentIdentity CreateFallbackIdentity(
|
||||
string? agentId,
|
||||
string? agentName,
|
||||
IReadOnlyDictionary<string, SubworkflowContext>? agentSubworkflowIndex)
|
||||
{
|
||||
string resolvedAgentId = !string.IsNullOrWhiteSpace(agentId)
|
||||
? agentId
|
||||
: agentName ?? "agent";
|
||||
string resolvedAgentName = !string.IsNullOrWhiteSpace(agentName)
|
||||
? agentName
|
||||
: resolvedAgentId;
|
||||
string resolvedAgentId = NormalizeOptionalString(agentId)
|
||||
?? NormalizeOptionalString(agentName)
|
||||
?? "agent";
|
||||
string resolvedAgentName = NormalizeOptionalString(agentName)
|
||||
?? resolvedAgentId;
|
||||
|
||||
if (agentSubworkflowIndex is not null
|
||||
&& agentSubworkflowIndex.TryGetValue(resolvedAgentId, out SubworkflowContext subworkflow))
|
||||
{
|
||||
return new AgentIdentity(resolvedAgentId, resolvedAgentName, subworkflow);
|
||||
}
|
||||
|
||||
return new AgentIdentity(resolvedAgentId, resolvedAgentName);
|
||||
}
|
||||
|
||||
private static bool MatchesAgent(PatternAgentDefinitionDto agent, string? candidate)
|
||||
private static void CollectAgentSubworkflowContexts(
|
||||
WorkflowDefinitionDto workflowDefinition,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
|
||||
SubworkflowContext? currentSubworkflow,
|
||||
Dictionary<string, SubworkflowContext> index,
|
||||
ISet<string> visitedWorkflowIds,
|
||||
ISet<WorkflowDefinitionDto> visitedAnonymousWorkflows)
|
||||
{
|
||||
string? workflowId = NormalizeOptionalString(workflowDefinition.Id);
|
||||
if (workflowId is not null)
|
||||
{
|
||||
if (!visitedWorkflowIds.Add(workflowId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
else if (!visitedAnonymousWorkflows.Add(workflowDefinition))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (WorkflowNodeDto node in workflowDefinition.Graph.Nodes)
|
||||
{
|
||||
if (node.IsAgentNode())
|
||||
{
|
||||
if (currentSubworkflow.HasValue)
|
||||
{
|
||||
index[node.GetAgentId()] = currentSubworkflow.Value;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!node.IsSubWorkflowNode())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
WorkflowDefinitionDto? subWorkflow = node.TryResolveSubWorkflowDefinition(workflowLibrary);
|
||||
if (subWorkflow is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
CollectAgentSubworkflowContexts(
|
||||
subWorkflow,
|
||||
workflowLibrary,
|
||||
CreateSubworkflowContext(node, workflowLibrary),
|
||||
index,
|
||||
visitedWorkflowIds,
|
||||
visitedAnonymousWorkflows);
|
||||
}
|
||||
}
|
||||
|
||||
private static SubworkflowContext CreateSubworkflowContext(
|
||||
WorkflowNodeDto node,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary)
|
||||
{
|
||||
return new SubworkflowContext(node.Id, node.GetSubworkflowDisplayName(workflowLibrary));
|
||||
}
|
||||
|
||||
private static bool MatchesAgent(WorkflowNodeDto agent, string? candidate)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.Equals(agent.Id, candidate, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(agent.Name, candidate, StringComparison.OrdinalIgnoreCase))
|
||||
string agentId = agent.GetAgentId();
|
||||
string agentName = agent.GetAgentName();
|
||||
if (string.Equals(agentId, candidate, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(agentName, candidate, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
string normalizedCandidate = NormalizeComparisonKey(candidate);
|
||||
string normalizedId = NormalizeComparisonKey(agent.Id);
|
||||
string normalizedName = NormalizeComparisonKey(agent.Name);
|
||||
string normalizedId = NormalizeComparisonKey(agentId);
|
||||
string normalizedName = NormalizeComparisonKey(agentName);
|
||||
if (normalizedCandidate.Length == 0)
|
||||
{
|
||||
return false;
|
||||
@@ -165,6 +409,9 @@ internal static class AgentIdentityResolver
|
||||
&& normalizedCandidate.Contains(normalizedName, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static string NormalizeComparisonKey(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
|
||||
@@ -5,12 +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 workspaceKind = "project",
|
||||
string interactionMode = "interactive",
|
||||
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.
|
||||
@@ -20,8 +25,16 @@ internal static class AgentInstructionComposer
|
||||
Answer conversationally and focus on the user's question directly.
|
||||
"""
|
||||
: string.Empty;
|
||||
string planModeGuidance = string.Equals(interactionMode, "plan", StringComparison.OrdinalIgnoreCase)
|
||||
? """
|
||||
You are operating in plan mode.
|
||||
Your job in this phase is to analyze the request, identify constraints, and produce a concrete implementation plan instead of carrying out the implementation.
|
||||
Once the plan is ready, call the built-in `exit_plan_mode` tool so the host can present the plan for review.
|
||||
Do not continue into implementation, file edits, builds, or tests after producing the plan unless the user explicitly asks to leave plan mode and proceed.
|
||||
"""
|
||||
: 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
|
||||
? """
|
||||
@@ -37,30 +50,21 @@ internal static class AgentInstructionComposer
|
||||
Focus on refining the answer already in progress.
|
||||
""";
|
||||
|
||||
return JoinInstructionBlocks(baseInstructions, workspaceGuidance, groupChatGuidance);
|
||||
return JoinInstructionBlocks(
|
||||
baseInstructions,
|
||||
repositoryInstructions,
|
||||
promptInvocationInstructions,
|
||||
workspaceGuidance,
|
||||
planModeGuidance,
|
||||
groupChatGuidance);
|
||||
}
|
||||
|
||||
if (!string.Equals(pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return JoinInstructionBlocks(baseInstructions, workspaceGuidance);
|
||||
}
|
||||
|
||||
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, workspaceGuidance, runtimeGuidance);
|
||||
return JoinInstructionBlocks(
|
||||
baseInstructions,
|
||||
repositoryInstructions,
|
||||
promptInvocationInstructions,
|
||||
workspaceGuidance,
|
||||
planModeGuidance);
|
||||
}
|
||||
|
||||
private static string JoinInstructionBlocks(params string[] blocks)
|
||||
@@ -69,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,193 +0,0 @@
|
||||
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 = [];
|
||||
|
||||
private CopilotAgentBundle(IReadOnlyList<AIAgent> agents)
|
||||
{
|
||||
Agents = agents;
|
||||
}
|
||||
|
||||
public IReadOnlyList<AIAgent> Agents { get; }
|
||||
|
||||
public static async Task<CopilotAgentBundle> CreateAsync(
|
||||
RunTurnCommandDto command,
|
||||
Func<PatternAgentDefinitionDto, PermissionRequest, PermissionInvocation, Task<PermissionRequestResult>> onPermissionRequest,
|
||||
Action<PatternAgentDefinitionDto, SessionEvent>? onSessionEvent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<IAsyncDisposable> disposables = [];
|
||||
List<AIAgent> agents = [];
|
||||
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions();
|
||||
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 = new()
|
||||
{
|
||||
Model = definition.Model,
|
||||
ReasoningEffort = definition.ReasoningEffort,
|
||||
SystemMessage = new SystemMessageConfig
|
||||
{
|
||||
Content = AgentInstructionComposer.Compose(command.Pattern, definition, agentIndex, command.WorkspaceKind),
|
||||
},
|
||||
WorkingDirectory = command.ProjectPath,
|
||||
OnPermissionRequest = (request, invocation) => onPermissionRequest(definition, request, invocation),
|
||||
OnEvent = evt => onSessionEvent?.Invoke(definition, evt),
|
||||
Streaming = true,
|
||||
};
|
||||
|
||||
ApplySessionTooling(sessionConfig, toolingBundle?.McpServers, toolingBundle?.Tools);
|
||||
|
||||
GitHubCopilotAgent 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);
|
||||
bundle._disposables.AddRange(disposables);
|
||||
return bundle;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
AIAgent entryAgent = agentMap.GetValueOrDefault(topology.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,315 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class CopilotApprovalCoordinator
|
||||
{
|
||||
private const string ApprovedDecision = "approved";
|
||||
private const string RejectedDecision = "rejected";
|
||||
private const string ToolCallApprovalKind = "tool-call";
|
||||
private const string WebFetchToolName = "web_fetch";
|
||||
|
||||
private readonly ConcurrentDictionary<string, PendingApprovalRequest> _pendingApprovals = new(StringComparer.Ordinal);
|
||||
|
||||
public Task ResolveApprovalAsync(
|
||||
ResolveApprovalCommandDto command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
|
||||
string approvalId = RequireApprovalId(command.ApprovalId);
|
||||
PendingApprovalRequest pending = GetPendingApproval(approvalId);
|
||||
PermissionRequestResultKind decision = ParseDecision(command.Decision);
|
||||
|
||||
if (!pending.Decision.TrySetResult(decision))
|
||||
{
|
||||
throw new InvalidOperationException($"Approval \"{approvalId}\" is no longer pending.");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task<PermissionRequestResult> RequestApprovalAsync(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agent,
|
||||
PermissionRequest request,
|
||||
PermissionInvocation invocation,
|
||||
IReadOnlyDictionary<string, string> toolNamesByCallId,
|
||||
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string? toolName = ResolveApprovalToolName(request, toolNamesByCallId);
|
||||
if (!RequiresToolCallApproval(command.Pattern.ApprovalPolicy, agent.Id, toolName))
|
||||
{
|
||||
return CreateApprovalResult(PermissionRequestResultKind.Approved);
|
||||
}
|
||||
|
||||
PendingApprovalRequest pending = CreatePendingApproval(command);
|
||||
if (!_pendingApprovals.TryAdd(pending.ApprovalId, pending))
|
||||
{
|
||||
throw new InvalidOperationException($"Approval \"{pending.ApprovalId}\" is already pending.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await onApproval(BuildPermissionApprovalEvent(
|
||||
command,
|
||||
agent,
|
||||
request,
|
||||
invocation,
|
||||
pending.ApprovalId,
|
||||
toolName))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
using CancellationTokenRegistration registration = cancellationToken.Register(
|
||||
static state =>
|
||||
{
|
||||
((TaskCompletionSource<PermissionRequestResultKind>)state!)
|
||||
.TrySetCanceled();
|
||||
},
|
||||
pending.Decision);
|
||||
|
||||
PermissionRequestResultKind decision = await pending.Decision.Task.ConfigureAwait(false);
|
||||
return CreateApprovalResult(decision);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pendingApprovals.TryRemove(pending.ApprovalId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
internal static ApprovalRequestedEventDto BuildPermissionApprovalEvent(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto 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? sessionId = NormalizeOptionalString(invocation.SessionId);
|
||||
string? normalizedToolName = NormalizeOptionalString(toolName);
|
||||
string? requestedUrl = request is PermissionRequestUrl urlRequest
|
||||
? NormalizeOptionalString(urlRequest.Url)
|
||||
: null;
|
||||
string title = normalizedToolName is null
|
||||
? $"Approve {permissionKind}"
|
||||
: $"Approve {normalizedToolName}";
|
||||
string detail = normalizedToolName is null
|
||||
? $"{agentName} requested {permissionKind} permission"
|
||||
: $"{agentName} requested {permissionKind} permission for tool \"{normalizedToolName}\"";
|
||||
|
||||
if (requestedUrl is not null)
|
||||
{
|
||||
detail = $"{detail} to access \"{requestedUrl}\"";
|
||||
}
|
||||
|
||||
if (sessionId is not null)
|
||||
{
|
||||
detail = normalizedToolName is null
|
||||
? $"{detail} for Copilot session {sessionId}"
|
||||
: $"{detail} in Copilot session {sessionId}";
|
||||
}
|
||||
|
||||
detail = $"{detail}.";
|
||||
|
||||
return new ApprovalRequestedEventDto
|
||||
{
|
||||
Type = "approval-requested",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
ApprovalId = approvalId,
|
||||
ApprovalKind = ToolCallApprovalKind,
|
||||
AgentId = NormalizeOptionalString(agent.Id),
|
||||
AgentName = NormalizeOptionalString(agentName),
|
||||
ToolName = normalizedToolName,
|
||||
PermissionKind = permissionKind,
|
||||
Title = title,
|
||||
Detail = detail,
|
||||
};
|
||||
}
|
||||
|
||||
internal static bool RequiresToolCallApproval(
|
||||
ApprovalPolicyDto? approvalPolicy,
|
||||
string agentId,
|
||||
string? toolName)
|
||||
{
|
||||
if (approvalPolicy?.Rules is null || approvalPolicy.Rules.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!HasMatchingToolCallCheckpoint(approvalPolicy.Rules, agentId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(toolName)
|
||||
|| !approvalPolicy.AutoApprovedToolNames.Any(candidate =>
|
||||
string.Equals(candidate, toolName, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
internal static bool TryGetApprovalToolName(
|
||||
PermissionRequest request,
|
||||
IReadOnlyDictionary<string, string>? toolNamesByCallId,
|
||||
out string? toolName)
|
||||
{
|
||||
toolName = ResolveApprovalToolName(request, toolNamesByCallId);
|
||||
return toolName is not null;
|
||||
}
|
||||
|
||||
internal static bool TryGetApprovalToolName(PermissionRequest request, out string? toolName)
|
||||
=> TryGetApprovalToolName(request, toolNamesByCallId: null, out toolName);
|
||||
|
||||
private static bool HasMatchingToolCallCheckpoint(
|
||||
IReadOnlyList<ApprovalCheckpointRuleDto> rules,
|
||||
string agentId)
|
||||
{
|
||||
foreach (ApprovalCheckpointRuleDto rule in rules)
|
||||
{
|
||||
if (!string.Equals(rule.Kind, ToolCallApprovalKind, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rule.AgentIds.Count == 0
|
||||
|| rule.AgentIds.Any(candidate =>
|
||||
string.Equals(candidate, agentId, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static PendingApprovalRequest CreatePendingApproval(RunTurnCommandDto command)
|
||||
{
|
||||
return new PendingApprovalRequest(
|
||||
command.RequestId,
|
||||
command.SessionId,
|
||||
CreateApprovalRequestId(),
|
||||
new TaskCompletionSource<PermissionRequestResultKind>(TaskCreationOptions.RunContinuationsAsynchronously));
|
||||
}
|
||||
|
||||
private static PermissionRequestResult CreateApprovalResult(PermissionRequestResultKind decision)
|
||||
{
|
||||
return new PermissionRequestResult
|
||||
{
|
||||
Kind = decision,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ResolveApprovalToolName(
|
||||
PermissionRequest request,
|
||||
IReadOnlyDictionary<string, string>? toolNamesByCallId)
|
||||
{
|
||||
return GetDirectToolName(request)
|
||||
?? ResolveToolNameFromLookup(request, toolNamesByCallId)
|
||||
?? GetFallbackToolName(request);
|
||||
}
|
||||
|
||||
private static string? GetDirectToolName(PermissionRequest request)
|
||||
{
|
||||
return request switch
|
||||
{
|
||||
PermissionRequestMcp mcp => NormalizeOptionalString(mcp.ToolName),
|
||||
PermissionRequestCustomTool customTool => NormalizeOptionalString(customTool.ToolName),
|
||||
PermissionRequestHook hook => NormalizeOptionalString(hook.ToolName),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ResolveToolNameFromLookup(
|
||||
PermissionRequest request,
|
||||
IReadOnlyDictionary<string, string>? toolNamesByCallId)
|
||||
{
|
||||
if (toolNamesByCallId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? toolCallId = GetToolCallId(request);
|
||||
if (toolCallId is null
|
||||
|| !toolNamesByCallId.TryGetValue(toolCallId, out string? resolvedToolName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return NormalizeOptionalString(resolvedToolName);
|
||||
}
|
||||
|
||||
private static string? GetToolCallId(PermissionRequest request)
|
||||
{
|
||||
return request switch
|
||||
{
|
||||
PermissionRequestShell shell => NormalizeOptionalString(shell.ToolCallId),
|
||||
PermissionRequestWrite write => NormalizeOptionalString(write.ToolCallId),
|
||||
PermissionRequestRead read => NormalizeOptionalString(read.ToolCallId),
|
||||
PermissionRequestMcp mcp => NormalizeOptionalString(mcp.ToolCallId),
|
||||
PermissionRequestUrl url => NormalizeOptionalString(url.ToolCallId),
|
||||
PermissionRequestMemory memory => NormalizeOptionalString(memory.ToolCallId),
|
||||
PermissionRequestCustomTool customTool => NormalizeOptionalString(customTool.ToolCallId),
|
||||
PermissionRequestHook hook => NormalizeOptionalString(hook.ToolCallId),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? GetFallbackToolName(PermissionRequest request)
|
||||
{
|
||||
return request switch
|
||||
{
|
||||
PermissionRequestUrl => WebFetchToolName,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private PendingApprovalRequest GetPendingApproval(string approvalId)
|
||||
{
|
||||
if (_pendingApprovals.TryGetValue(approvalId, out PendingApprovalRequest? pending))
|
||||
{
|
||||
return pending;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Approval \"{approvalId}\" is not pending.");
|
||||
}
|
||||
|
||||
private static string RequireApprovalId(string? approvalId)
|
||||
{
|
||||
string? normalizedApprovalId = NormalizeOptionalString(approvalId);
|
||||
return normalizedApprovalId
|
||||
?? throw new InvalidOperationException("Approval ID is required.");
|
||||
}
|
||||
|
||||
private static PermissionRequestResultKind ParseDecision(string? decision)
|
||||
{
|
||||
return NormalizeOptionalString(decision)?.ToLowerInvariant() switch
|
||||
{
|
||||
ApprovedDecision => PermissionRequestResultKind.Approved,
|
||||
RejectedDecision => PermissionRequestResultKind.DeniedInteractivelyByUser,
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Unsupported approval decision \"{decision}\"."),
|
||||
};
|
||||
}
|
||||
|
||||
private static string CreateApprovalRequestId()
|
||||
{
|
||||
return $"approval-{Guid.NewGuid():N}";
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private sealed record PendingApprovalRequest(
|
||||
string RequestId,
|
||||
string SessionId,
|
||||
string ApprovalId,
|
||||
TaskCompletionSource<PermissionRequestResultKind> Decision);
|
||||
}
|
||||
@@ -1,140 +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 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 async Task EmitThinkingIfNeeded(
|
||||
AgentIdentity agent,
|
||||
Func<AgentActivityEventDto, Task> onActivity)
|
||||
{
|
||||
ActiveAgent = agent;
|
||||
|
||||
if (!_startedAgents.Add(agent.AgentId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await onActivity(new AgentActivityEventDto
|
||||
{
|
||||
Type = "agent-activity",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
ActivityType = "thinking",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public void ApplyActivity(AgentActivityEventDto activity)
|
||||
{
|
||||
if (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);
|
||||
break;
|
||||
case AssistantMessageEvent assistantMessage when !string.IsNullOrWhiteSpace(assistantMessage.Data?.MessageId):
|
||||
RecordObservedAgentForMessage(agent, assistantMessage.Data!.MessageId);
|
||||
break;
|
||||
case AssistantReasoningDeltaEvent:
|
||||
ActiveAgent = agent;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
return base.FinalizeCompletedMessages(CopilotTranscriptProjector.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
{
|
||||
private readonly PatternValidator _patternValidator;
|
||||
private readonly CopilotApprovalCoordinator _approvalCoordinator = new();
|
||||
|
||||
public CopilotWorkflowRunner(PatternValidator patternValidator)
|
||||
{
|
||||
_patternValidator = patternValidator;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
|
||||
RunTurnCommandDto command,
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<AgentActivityEventDto, Task> onActivity,
|
||||
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
PatternValidationIssueDto? validationError = _patternValidator.Validate(command.Pattern).FirstOrDefault();
|
||||
if (validationError is not null)
|
||||
{
|
||||
throw new InvalidOperationException(validationError.Message);
|
||||
}
|
||||
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
await using CopilotAgentBundle bundle = await CopilotAgentBundle.CreateAsync(
|
||||
command,
|
||||
(agent, request, invocation) => _approvalCoordinator.RequestApprovalAsync(
|
||||
command,
|
||||
agent,
|
||||
request,
|
||||
invocation,
|
||||
state.ToolNamesByCallId,
|
||||
onApproval,
|
||||
cancellationToken),
|
||||
(agent, sessionEvent) => state.ObserveSessionEvent(agent, sessionEvent),
|
||||
cancellationToken);
|
||||
Workflow workflow = bundle.BuildWorkflow(command.Pattern);
|
||||
List<ChatMessage> inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList();
|
||||
|
||||
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(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
bool shouldEndTurn = await HandleWorkflowEventAsync(command, evt, inputMessages, state, onDelta, onActivity)
|
||||
.ConfigureAwait(false);
|
||||
if (shouldEndTurn)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return state.FinalizeCompletedMessages();
|
||||
}
|
||||
|
||||
public Task ResolveApprovalAsync(
|
||||
ResolveApprovalCommandDto command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return _approvalCoordinator.ResolveApprovalAsync(command, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<bool> HandleWorkflowEventAsync(
|
||||
RunTurnCommandDto command,
|
||||
WorkflowEvent evt,
|
||||
IReadOnlyList<ChatMessage> inputMessages,
|
||||
CopilotTurnExecutionState state,
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<AgentActivityEventDto, Task> onActivity)
|
||||
{
|
||||
if (evt is ExecutorInvokedEvent invoked
|
||||
&& AgentIdentityResolver.TryResolveKnownAgentIdentity(
|
||||
command.Pattern,
|
||||
invoked.ExecutorId,
|
||||
out AgentIdentity invokedAgent))
|
||||
{
|
||||
await state.EmitThinkingIfNeeded(invokedAgent, onActivity).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (evt is RequestInfoEvent requestInfo)
|
||||
{
|
||||
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
|
||||
command,
|
||||
requestInfo,
|
||||
state.ActiveAgent,
|
||||
state.ToolNamesByCallId);
|
||||
|
||||
if (activity is null)
|
||||
{
|
||||
return WorkflowRequestInfoInterpreter.RequiresUserInputTurnBoundary(command, requestInfo);
|
||||
}
|
||||
|
||||
state.ApplyActivity(activity);
|
||||
await onActivity(activity).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (evt is AgentResponseUpdateEvent update)
|
||||
{
|
||||
await HandleAgentResponseUpdateAsync(command, update, state, onDelta, onActivity).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (evt is ExecutorCompletedEvent completed
|
||||
&& AgentIdentityResolver.TryResolveObservedAgentIdentity(
|
||||
command.Pattern,
|
||||
completed.ExecutorId,
|
||||
state.ActiveAgent,
|
||||
out AgentIdentity completedAgent))
|
||||
{
|
||||
state.ClearActiveAgentIfMatching(completedAgent);
|
||||
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<AgentActivityEventDto, Task> onActivity)
|
||||
{
|
||||
AgentIdentity? updateAgent = null;
|
||||
string authorName = update.ExecutorId;
|
||||
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)
|
||||
{
|
||||
await state.EmitThinkingIfNeeded(updateAgent.Value, onActivity).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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,220 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal interface IHookCommandRunner
|
||||
{
|
||||
Task<string?> RunAsync(
|
||||
HookCommandDefinition hook,
|
||||
string inputJson,
|
||||
string projectPath,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class HookCommandRunner : IHookCommandRunner
|
||||
{
|
||||
private const int DefaultTimeoutSeconds = 30;
|
||||
|
||||
public static HookCommandRunner Instance { get; } = new();
|
||||
|
||||
public async Task<string?> RunAsync(
|
||||
HookCommandDefinition hook,
|
||||
string inputJson,
|
||||
string projectPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(hook);
|
||||
ArgumentNullException.ThrowIfNull(inputJson);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(projectPath);
|
||||
|
||||
string? commandText = SelectCommandText(hook);
|
||||
if (commandText is null)
|
||||
{
|
||||
Console.Error.WriteLine("[aryx hooks] Skipping hook because no compatible shell command is configured for this platform.");
|
||||
return null;
|
||||
}
|
||||
|
||||
string workingDirectory = ResolveWorkingDirectory(projectPath, hook.Cwd);
|
||||
ProcessStartInfo startInfo = CreateStartInfo(commandText, workingDirectory);
|
||||
ApplyEnvironment(startInfo, hook.Env);
|
||||
|
||||
using Process process = new()
|
||||
{
|
||||
StartInfo = startInfo,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
if (!process.Start())
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Failed to start hook command '{commandText}'.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
catch (Win32Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Failed to start hook command '{commandText}': {exception.Message}");
|
||||
return null;
|
||||
}
|
||||
catch (InvalidOperationException exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Failed to start hook command '{commandText}': {exception.Message}");
|
||||
return null;
|
||||
}
|
||||
|
||||
Task<string> stdoutTask = process.StandardOutput.ReadToEndAsync();
|
||||
Task<string> stderrTask = process.StandardError.ReadToEndAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await process.StandardInput.WriteAsync(inputJson).ConfigureAwait(false);
|
||||
await process.StandardInput.FlushAsync().ConfigureAwait(false);
|
||||
process.StandardInput.Close();
|
||||
}
|
||||
catch (IOException exception)
|
||||
{
|
||||
TryKillProcess(process);
|
||||
Console.Error.WriteLine($"[aryx hooks] Failed to write hook input for '{commandText}': {exception.Message}");
|
||||
return null;
|
||||
}
|
||||
catch (ObjectDisposedException exception)
|
||||
{
|
||||
TryKillProcess(process);
|
||||
Console.Error.WriteLine($"[aryx hooks] Failed to write hook input for '{commandText}': {exception.Message}");
|
||||
return null;
|
||||
}
|
||||
|
||||
TimeSpan timeout = TimeSpan.FromSeconds(hook.TimeoutSec ?? DefaultTimeoutSeconds);
|
||||
using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(timeout);
|
||||
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
TryKillProcess(process);
|
||||
await DrainOutputAsync(process, stdoutTask, stderrTask).ConfigureAwait(false);
|
||||
Console.Error.WriteLine($"[aryx hooks] Hook command timed out after {(int)timeout.TotalSeconds} seconds: '{commandText}'.");
|
||||
return null;
|
||||
}
|
||||
|
||||
string stdout = await stdoutTask.ConfigureAwait(false);
|
||||
string stderr = await stderrTask.ConfigureAwait(false);
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
string detail = string.IsNullOrWhiteSpace(stderr) ? $"exit code {process.ExitCode}" : stderr.Trim();
|
||||
Console.Error.WriteLine($"[aryx hooks] Hook command failed for '{commandText}': {detail}");
|
||||
return null;
|
||||
}
|
||||
|
||||
return stdout;
|
||||
}
|
||||
|
||||
private static async Task DrainOutputAsync(Process process, Task<string> stdoutTask, Task<string> stderrTask)
|
||||
{
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Process already exited or could not be waited on.
|
||||
}
|
||||
|
||||
await Task.WhenAll(stdoutTask, stderrTask).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static ProcessStartInfo CreateStartInfo(string commandText, string workingDirectory)
|
||||
{
|
||||
ProcessStartInfo startInfo = new()
|
||||
{
|
||||
WorkingDirectory = workingDirectory,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
startInfo.FileName = "powershell.exe";
|
||||
startInfo.ArgumentList.Add("-NoLogo");
|
||||
startInfo.ArgumentList.Add("-NoProfile");
|
||||
startInfo.ArgumentList.Add("-NonInteractive");
|
||||
startInfo.ArgumentList.Add("-ExecutionPolicy");
|
||||
startInfo.ArgumentList.Add("Bypass");
|
||||
startInfo.ArgumentList.Add("-Command");
|
||||
startInfo.ArgumentList.Add(commandText);
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
startInfo.FileName = "bash";
|
||||
startInfo.ArgumentList.Add("-lc");
|
||||
startInfo.ArgumentList.Add(commandText);
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static void ApplyEnvironment(ProcessStartInfo startInfo, IReadOnlyDictionary<string, string>? environment)
|
||||
{
|
||||
if (environment is not { Count: > 0 })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ((string key, string value) in environment)
|
||||
{
|
||||
startInfo.Environment[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveWorkingDirectory(string projectPath, string? configuredCwd)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configuredCwd))
|
||||
{
|
||||
return Path.GetFullPath(projectPath);
|
||||
}
|
||||
|
||||
string resolved = Path.IsPathRooted(configuredCwd)
|
||||
? configuredCwd
|
||||
: Path.Combine(projectPath, configuredCwd);
|
||||
|
||||
return Path.GetFullPath(resolved);
|
||||
}
|
||||
|
||||
private static string? SelectCommandText(HookCommandDefinition hook)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return NormalizeOptionalString(hook.PowerShell);
|
||||
}
|
||||
|
||||
return NormalizeOptionalString(hook.Bash);
|
||||
}
|
||||
|
||||
private static void TryKillProcess(Process process)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!process.HasExited)
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Process already exited.
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
// The platform does not support process tree termination.
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
using System.Text.Json;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal static class HookConfigLoader
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
|
||||
|
||||
public static async Task<ResolvedHookSet> LoadAsync(string projectPath, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(projectPath);
|
||||
|
||||
string hooksDirectory = Path.Combine(projectPath, ".github", "hooks");
|
||||
if (!Directory.Exists(hooksDirectory))
|
||||
{
|
||||
return ResolvedHookSet.Empty;
|
||||
}
|
||||
|
||||
string[] hookFiles;
|
||||
try
|
||||
{
|
||||
hookFiles = Directory.GetFiles(hooksDirectory, "*.json", SearchOption.TopDirectoryOnly);
|
||||
}
|
||||
catch (IOException exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Failed to enumerate hook files in '{hooksDirectory}': {exception.Message}");
|
||||
return ResolvedHookSet.Empty;
|
||||
}
|
||||
catch (UnauthorizedAccessException exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Failed to enumerate hook files in '{hooksDirectory}': {exception.Message}");
|
||||
return ResolvedHookSet.Empty;
|
||||
}
|
||||
|
||||
if (hookFiles.Length == 0)
|
||||
{
|
||||
return ResolvedHookSet.Empty;
|
||||
}
|
||||
|
||||
Array.Sort(hookFiles, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
List<HookCommandDefinition> sessionStart = [];
|
||||
List<HookCommandDefinition> sessionEnd = [];
|
||||
List<HookCommandDefinition> userPromptSubmitted = [];
|
||||
List<HookCommandDefinition> preToolUse = [];
|
||||
List<HookCommandDefinition> postToolUse = [];
|
||||
List<HookCommandDefinition> errorOccurred = [];
|
||||
|
||||
foreach (string hookFile in hookFiles)
|
||||
{
|
||||
HookConfigFile? config = await ReadHookConfigAsync(hookFile, cancellationToken).ConfigureAwait(false);
|
||||
if (config is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (config.Version != 1)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Skipping '{hookFile}' because it declares unsupported version '{config.Version}'.");
|
||||
continue;
|
||||
}
|
||||
|
||||
AddHooks(sessionStart, config.Hooks.SessionStart, HookTypeNames.SessionStart, hookFile);
|
||||
AddHooks(sessionEnd, config.Hooks.SessionEnd, HookTypeNames.SessionEnd, hookFile);
|
||||
AddHooks(userPromptSubmitted, config.Hooks.UserPromptSubmitted, HookTypeNames.UserPromptSubmitted, hookFile);
|
||||
AddHooks(preToolUse, config.Hooks.PreToolUse, HookTypeNames.PreToolUse, hookFile);
|
||||
AddHooks(postToolUse, config.Hooks.PostToolUse, HookTypeNames.PostToolUse, hookFile);
|
||||
AddHooks(errorOccurred, config.Hooks.ErrorOccurred, HookTypeNames.ErrorOccurred, hookFile);
|
||||
}
|
||||
|
||||
if (
|
||||
sessionStart.Count == 0
|
||||
&& sessionEnd.Count == 0
|
||||
&& userPromptSubmitted.Count == 0
|
||||
&& preToolUse.Count == 0
|
||||
&& postToolUse.Count == 0
|
||||
&& errorOccurred.Count == 0)
|
||||
{
|
||||
return ResolvedHookSet.Empty;
|
||||
}
|
||||
|
||||
return new ResolvedHookSet
|
||||
{
|
||||
SessionStart = [.. sessionStart],
|
||||
SessionEnd = [.. sessionEnd],
|
||||
UserPromptSubmitted = [.. userPromptSubmitted],
|
||||
PreToolUse = [.. preToolUse],
|
||||
PostToolUse = [.. postToolUse],
|
||||
ErrorOccurred = [.. errorOccurred],
|
||||
};
|
||||
}
|
||||
|
||||
private static void AddHooks(
|
||||
ICollection<HookCommandDefinition> target,
|
||||
IReadOnlyList<HookCommandDefinition>? definitions,
|
||||
string hookType,
|
||||
string hookFile)
|
||||
{
|
||||
if (definitions is not { Count: > 0 })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (HookCommandDefinition definition in definitions)
|
||||
{
|
||||
HookCommandDefinition? normalized = NormalizeDefinition(definition, hookType, hookFile);
|
||||
if (normalized is not null)
|
||||
{
|
||||
target.Add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static HookCommandDefinition? NormalizeDefinition(
|
||||
HookCommandDefinition definition,
|
||||
string hookType,
|
||||
string hookFile)
|
||||
{
|
||||
string type = NormalizeOptionalString(definition.Type) ?? string.Empty;
|
||||
if (!string.Equals(type, "command", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Skipping '{hookType}' entry in '{hookFile}' because type '{definition.Type}' is unsupported.");
|
||||
return null;
|
||||
}
|
||||
|
||||
string? bash = NormalizeOptionalString(definition.Bash);
|
||||
string? powerShell = NormalizeOptionalString(definition.PowerShell);
|
||||
if (bash is null && powerShell is null)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Skipping '{hookType}' entry in '{hookFile}' because no shell command is configured.");
|
||||
return null;
|
||||
}
|
||||
|
||||
int? timeoutSec = definition.TimeoutSec;
|
||||
if (timeoutSec is <= 0)
|
||||
{
|
||||
timeoutSec = null;
|
||||
}
|
||||
|
||||
IReadOnlyDictionary<string, string>? env = NormalizeEnvironment(definition.Env);
|
||||
|
||||
return new HookCommandDefinition
|
||||
{
|
||||
Type = "command",
|
||||
Bash = bash,
|
||||
PowerShell = powerShell,
|
||||
Cwd = NormalizeOptionalString(definition.Cwd),
|
||||
Env = env,
|
||||
TimeoutSec = timeoutSec,
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<HookConfigFile?> ReadHookConfigAsync(string hookFile, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using FileStream stream = File.OpenRead(hookFile);
|
||||
return await JsonSerializer.DeserializeAsync<HookConfigFile>(stream, JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Failed to parse '{hookFile}': {exception.Message}");
|
||||
return null;
|
||||
}
|
||||
catch (IOException exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Failed to read '{hookFile}': {exception.Message}");
|
||||
return null;
|
||||
}
|
||||
catch (UnauthorizedAccessException exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Failed to read '{hookFile}': {exception.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string>? NormalizeEnvironment(IReadOnlyDictionary<string, string>? environment)
|
||||
{
|
||||
if (environment is not { Count: > 0 })
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Dictionary<string, string> normalized = new(StringComparer.Ordinal);
|
||||
foreach ((string key, string value) in environment)
|
||||
{
|
||||
string? normalizedKey = NormalizeOptionalString(key);
|
||||
if (normalizedKey is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized[normalizedKey] = value;
|
||||
}
|
||||
|
||||
return normalized.Count == 0 ? null : normalized;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static JsonSerializerOptions CreateJsonOptions()
|
||||
{
|
||||
JsonSerializerOptions options = JsonSerialization.CreateWebOptions();
|
||||
options.AllowTrailingCommas = true;
|
||||
options.PropertyNameCaseInsensitive = true;
|
||||
options.ReadCommentHandling = JsonCommentHandling.Skip;
|
||||
return options;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -7,11 +7,18 @@ public interface ITurnWorkflowRunner
|
||||
Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
|
||||
RunTurnCommandDto command,
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<AgentActivityEventDto, Task> onActivity,
|
||||
Func<SidecarEventDto, Task> onEvent,
|
||||
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||
Func<UserInputRequestedEventDto, Task> onUserInput,
|
||||
Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired,
|
||||
Func<ExitPlanModeRequestedEventDto, Task> onExitPlanMode,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task ResolveApprovalAsync(
|
||||
ResolveApprovalCommandDto command,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task ResolveUserInputAsync(
|
||||
ResolveUserInputCommandDto command,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal static class JsonSerialization
|
||||
{
|
||||
public static JsonSerializerOptions CreateWebOptions()
|
||||
{
|
||||
return new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
||||
{
|
||||
TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using System.Collections;
|
||||
using OpenTelemetry;
|
||||
using OpenTelemetry.Exporter;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal static class OpenTelemetrySetup
|
||||
{
|
||||
private const string ServiceName = "Aryx.AgentHost";
|
||||
private const string EndpointEnvironmentVariableName = "OTEL_EXPORTER_OTLP_ENDPOINT";
|
||||
private const string ProtocolEnvironmentVariableName = "OTEL_EXPORTER_OTLP_PROTOCOL";
|
||||
|
||||
private static readonly string[] ActivitySourceNames =
|
||||
[
|
||||
"Experimental.Microsoft.Agents.AI",
|
||||
"Microsoft.Agents.AI.Workflows",
|
||||
];
|
||||
|
||||
public static TracerProvider? CreateTracerProviderFromEnvironment(TextWriter? diagnosticsWriter = null)
|
||||
{
|
||||
return CreateTracerProvider(ReadEnvironmentVariables(), diagnosticsWriter ?? Console.Error);
|
||||
}
|
||||
|
||||
internal static TracerProvider? CreateTracerProvider(
|
||||
IEnumerable<KeyValuePair<string, string?>> environmentVariables,
|
||||
TextWriter diagnosticsWriter)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(environmentVariables);
|
||||
ArgumentNullException.ThrowIfNull(diagnosticsWriter);
|
||||
|
||||
OpenTelemetryTracingConfiguration? configuration = ResolveTracingConfiguration(environmentVariables);
|
||||
if (configuration is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
diagnosticsWriter.WriteLine(
|
||||
$"Aryx.AgentHost OpenTelemetry tracing enabled ({configuration.ProtocolLabel}) -> {configuration.Endpoint}.");
|
||||
|
||||
return Sdk.CreateTracerProviderBuilder()
|
||||
.SetResourceBuilder(
|
||||
ResourceBuilder.CreateDefault()
|
||||
.AddService(ServiceName, serviceVersion: ResolveServiceVersion()))
|
||||
.AddSource(configuration.ActivitySourceNames.ToArray())
|
||||
.AddOtlpExporter(options =>
|
||||
{
|
||||
options.Endpoint = configuration.Endpoint;
|
||||
options.Protocol = configuration.Protocol;
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
internal static OpenTelemetryTracingConfiguration? ResolveTracingConfiguration(
|
||||
IEnumerable<KeyValuePair<string, string?>> environmentVariables)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(environmentVariables);
|
||||
|
||||
string? endpointValue = ReadSetting(environmentVariables, EndpointEnvironmentVariableName);
|
||||
if (string.IsNullOrWhiteSpace(endpointValue))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(endpointValue, UriKind.Absolute, out Uri? endpoint)
|
||||
|| (endpoint.Scheme != Uri.UriSchemeHttp && endpoint.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{EndpointEnvironmentVariableName} must be an absolute http or https URL. Received '{endpointValue}'.");
|
||||
}
|
||||
|
||||
OtlpExportProtocol protocol = ResolveProtocol(ReadSetting(environmentVariables, ProtocolEnvironmentVariableName));
|
||||
return new OpenTelemetryTracingConfiguration(endpoint, protocol, ActivitySourceNames);
|
||||
}
|
||||
|
||||
private static string? ResolveServiceVersion()
|
||||
{
|
||||
return typeof(OpenTelemetrySetup).Assembly.GetName().Version?.ToString();
|
||||
}
|
||||
|
||||
private static IEnumerable<KeyValuePair<string, string?>> ReadEnvironmentVariables()
|
||||
{
|
||||
return Environment.GetEnvironmentVariables()
|
||||
.Cast<DictionaryEntry>()
|
||||
.Select(entry => new KeyValuePair<string, string?>(
|
||||
entry.Key?.ToString() ?? string.Empty,
|
||||
entry.Value?.ToString()));
|
||||
}
|
||||
|
||||
private static string? ReadSetting(
|
||||
IEnumerable<KeyValuePair<string, string?>> environmentVariables,
|
||||
string name)
|
||||
{
|
||||
foreach (KeyValuePair<string, string?> entry in environmentVariables)
|
||||
{
|
||||
if (!string.Equals(entry.Key, name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(entry.Value)
|
||||
? null
|
||||
: entry.Value.Trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static OtlpExportProtocol ResolveProtocol(string? protocolValue)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(protocolValue))
|
||||
{
|
||||
return OtlpExportProtocol.Grpc;
|
||||
}
|
||||
|
||||
return protocolValue.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"grpc" => OtlpExportProtocol.Grpc,
|
||||
"http/protobuf" => OtlpExportProtocol.HttpProtobuf,
|
||||
_ => throw new InvalidOperationException(
|
||||
$"{ProtocolEnvironmentVariableName} must be 'grpc' or 'http/protobuf'. Received '{protocolValue}'."),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record OpenTelemetryTracingConfiguration(
|
||||
Uri Endpoint,
|
||||
OtlpExportProtocol Protocol,
|
||||
IReadOnlyList<string> ActivitySourceNames)
|
||||
{
|
||||
public string ProtocolLabel => Protocol switch
|
||||
{
|
||||
OtlpExportProtocol.HttpProtobuf => "http/protobuf",
|
||||
_ => "grpc",
|
||||
};
|
||||
}
|
||||
@@ -1,345 +0,0 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed record PatternHandoffRoute(string SourceAgentId, string TargetAgentId);
|
||||
|
||||
internal sealed record PatternHandoffTopology(string EntryAgentId, IReadOnlyList<PatternHandoffRoute> Routes);
|
||||
|
||||
internal static class PatternGraphResolver
|
||||
{
|
||||
private const string UserInputKind = "user-input";
|
||||
private const string UserOutputKind = "user-output";
|
||||
private const string AgentKind = "agent";
|
||||
private const string DistributorKind = "distributor";
|
||||
private const string CollectorKind = "collector";
|
||||
private const string OrchestratorKind = "orchestrator";
|
||||
|
||||
private static readonly StringComparer Comparer = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
public static PatternGraphDto Resolve(PatternDefinitionDto pattern)
|
||||
=> pattern.Graph ?? CreateDefault(pattern);
|
||||
|
||||
public static IReadOnlyList<string> ResolveOrderedAgentIds(PatternDefinitionDto pattern)
|
||||
{
|
||||
PatternGraphDto graph = Resolve(pattern);
|
||||
|
||||
return pattern.Mode switch
|
||||
{
|
||||
"single" or "sequential" or "magentic" => ResolveLinearAgentIds(pattern, graph),
|
||||
"concurrent" or "group-chat" or "handoff" => ResolveAgentOrder(pattern, graph),
|
||||
_ => pattern.Agents.Select(agent => agent.Id).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public static PatternHandoffTopology ResolveHandoff(PatternDefinitionDto pattern)
|
||||
{
|
||||
return TryResolveHandoff(pattern, Resolve(pattern))
|
||||
?? TryResolveHandoff(pattern, CreateDefault(pattern))
|
||||
?? new PatternHandoffTopology(
|
||||
pattern.Agents.FirstOrDefault()?.Id ?? string.Empty,
|
||||
[]);
|
||||
}
|
||||
|
||||
public static PatternGraphDto CreateDefault(PatternDefinitionDto pattern)
|
||||
{
|
||||
return pattern.Mode switch
|
||||
{
|
||||
"single" or "sequential" or "magentic" => CreateLinearGraph(pattern.Agents),
|
||||
"concurrent" => CreateConcurrentGraph(pattern.Agents),
|
||||
"handoff" => CreateHandoffGraph(pattern.Agents),
|
||||
"group-chat" => CreateGroupChatGraph(pattern.Agents),
|
||||
_ => CreateLinearGraph(pattern.Agents)
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ResolveLinearAgentIds(PatternDefinitionDto pattern, PatternGraphDto graph)
|
||||
{
|
||||
PatternGraphNodeDto? inputNode = GetNodeByKind(graph, UserInputKind);
|
||||
PatternGraphNodeDto? outputNode = GetNodeByKind(graph, UserOutputKind);
|
||||
if (inputNode is null || outputNode is null)
|
||||
{
|
||||
return pattern.Agents.Select(agent => agent.Id).ToList();
|
||||
}
|
||||
|
||||
Dictionary<string, PatternGraphNodeDto> nodesById = graph.Nodes.ToDictionary(node => node.Id, node => node);
|
||||
Dictionary<string, List<PatternGraphEdgeDto>> outgoing = BuildOutgoingLookup(graph);
|
||||
List<string> orderedAgentIds = [];
|
||||
HashSet<string> visitedNodeIds = [];
|
||||
string currentNodeId = inputNode.Id;
|
||||
|
||||
while (visitedNodeIds.Add(currentNodeId))
|
||||
{
|
||||
if (!outgoing.TryGetValue(currentNodeId, out List<PatternGraphEdgeDto>? edges) || edges.Count != 1)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
string nextNodeId = edges[0].Target;
|
||||
if (!nodesById.TryGetValue(nextNodeId, out PatternGraphNodeDto? nextNode))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (Comparer.Equals(nextNode.Id, outputNode.Id))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (Comparer.Equals(nextNode.Kind, AgentKind) && !string.IsNullOrWhiteSpace(nextNode.AgentId))
|
||||
{
|
||||
orderedAgentIds.Add(nextNode.AgentId);
|
||||
}
|
||||
|
||||
currentNodeId = nextNodeId;
|
||||
}
|
||||
|
||||
return orderedAgentIds.Count == pattern.Agents.Count
|
||||
? orderedAgentIds
|
||||
: pattern.Agents.Select(agent => agent.Id).ToList();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ResolveAgentOrder(PatternDefinitionDto pattern, PatternGraphDto graph)
|
||||
{
|
||||
Dictionary<string, int> fallbackOrder = pattern.Agents
|
||||
.Select((agent, index) => new { agent.Id, Index = index })
|
||||
.ToDictionary(item => item.Id, item => item.Index);
|
||||
|
||||
List<string> orderedAgentIds = graph.Nodes
|
||||
.Where(node => Comparer.Equals(node.Kind, AgentKind) && !string.IsNullOrWhiteSpace(node.AgentId))
|
||||
.OrderBy(node => node.Order ?? int.MaxValue)
|
||||
.ThenBy(node => fallbackOrder.GetValueOrDefault(node.AgentId!, int.MaxValue))
|
||||
.Select(node => node.AgentId!)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
return orderedAgentIds.Count == pattern.Agents.Count
|
||||
? orderedAgentIds
|
||||
: pattern.Agents.Select(agent => agent.Id).ToList();
|
||||
}
|
||||
|
||||
private static PatternHandoffTopology? TryResolveHandoff(PatternDefinitionDto pattern, PatternGraphDto graph)
|
||||
{
|
||||
Dictionary<string, PatternGraphNodeDto> nodesById = graph.Nodes.ToDictionary(node => node.Id, node => node);
|
||||
PatternGraphNodeDto? inputNode = GetNodeByKind(graph, UserInputKind);
|
||||
string? entryAgentId = null;
|
||||
|
||||
if (inputNode is not null)
|
||||
{
|
||||
entryAgentId = graph.Edges
|
||||
.Where(edge => Comparer.Equals(edge.Source, inputNode.Id))
|
||||
.Select(edge => nodesById.TryGetValue(edge.Target, out PatternGraphNodeDto? targetNode)
|
||||
? targetNode.AgentId
|
||||
: null)
|
||||
.FirstOrDefault(agentId => !string.IsNullOrWhiteSpace(agentId));
|
||||
}
|
||||
|
||||
List<PatternHandoffRoute> routes = graph.Edges
|
||||
.Select(edge => (SourceNode: nodesById.GetValueOrDefault(edge.Source), TargetNode: nodesById.GetValueOrDefault(edge.Target)))
|
||||
.Where(item =>
|
||||
item.SourceNode is not null
|
||||
&& item.TargetNode is not null
|
||||
&& Comparer.Equals(item.SourceNode.Kind, AgentKind)
|
||||
&& Comparer.Equals(item.TargetNode.Kind, AgentKind)
|
||||
&& !string.IsNullOrWhiteSpace(item.SourceNode.AgentId)
|
||||
&& !string.IsNullOrWhiteSpace(item.TargetNode.AgentId))
|
||||
.Select(item => new PatternHandoffRoute(item.SourceNode!.AgentId!, item.TargetNode!.AgentId!))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(entryAgentId) || routes.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PatternHandoffTopology(entryAgentId!, routes);
|
||||
}
|
||||
|
||||
private static Dictionary<string, List<PatternGraphEdgeDto>> BuildOutgoingLookup(PatternGraphDto graph)
|
||||
{
|
||||
Dictionary<string, List<PatternGraphEdgeDto>> lookup = new(StringComparer.Ordinal);
|
||||
foreach (PatternGraphNodeDto node in graph.Nodes)
|
||||
{
|
||||
lookup[node.Id] = [];
|
||||
}
|
||||
|
||||
foreach (PatternGraphEdgeDto edge in graph.Edges)
|
||||
{
|
||||
if (!lookup.TryGetValue(edge.Source, out List<PatternGraphEdgeDto>? edges))
|
||||
{
|
||||
edges = [];
|
||||
lookup[edge.Source] = edges;
|
||||
}
|
||||
|
||||
edges.Add(edge);
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
private static PatternGraphNodeDto? GetNodeByKind(PatternGraphDto graph, string kind)
|
||||
=> graph.Nodes.FirstOrDefault(node => Comparer.Equals(node.Kind, kind));
|
||||
|
||||
private static PatternGraphDto CreateLinearGraph(IReadOnlyList<PatternAgentDefinitionDto> agents)
|
||||
{
|
||||
PatternGraphNodeDto inputNode = CreateSystemNode("system-user-input", UserInputKind, 0, 0);
|
||||
PatternGraphNodeDto outputNode = CreateSystemNode("system-user-output", UserOutputKind, 220 * Math.Max(agents.Count + 1, 2), 0);
|
||||
List<PatternGraphNodeDto> agentNodes = agents
|
||||
.Select((agent, index) => CreateAgentNode(agent, index, 220 * (index + 1), 0))
|
||||
.ToList();
|
||||
List<PatternGraphEdgeDto> edges = [];
|
||||
List<string> path = [inputNode.Id, .. agentNodes.Select(node => node.Id), outputNode.Id];
|
||||
for (int index = 0; index < path.Count - 1; index += 1)
|
||||
{
|
||||
edges.Add(CreateEdge(path[index], path[index + 1]));
|
||||
}
|
||||
|
||||
return new PatternGraphDto
|
||||
{
|
||||
Nodes = [inputNode, .. agentNodes, outputNode],
|
||||
Edges = edges
|
||||
};
|
||||
}
|
||||
|
||||
private static PatternGraphDto CreateConcurrentGraph(IReadOnlyList<PatternAgentDefinitionDto> agents)
|
||||
{
|
||||
PatternGraphNodeDto inputNode = CreateSystemNode("system-user-input", UserInputKind, 0, 0);
|
||||
PatternGraphNodeDto distributorNode = CreateSystemNode("system-distributor", DistributorKind, 190, 0);
|
||||
PatternGraphNodeDto collectorNode = CreateSystemNode("system-collector", CollectorKind, 650, 0);
|
||||
PatternGraphNodeDto outputNode = CreateSystemNode("system-user-output", UserOutputKind, 860, 0);
|
||||
List<PatternGraphNodeDto> agentNodes = agents
|
||||
.Select((agent, index) => CreateAgentNode(agent, index, 430, SpreadY(index, Math.Max(agents.Count, 1), 170)))
|
||||
.ToList();
|
||||
|
||||
return new PatternGraphDto
|
||||
{
|
||||
Nodes = [inputNode, distributorNode, .. agentNodes, collectorNode, outputNode],
|
||||
Edges =
|
||||
[
|
||||
CreateEdge(inputNode.Id, distributorNode.Id),
|
||||
.. agentNodes.Select(node => CreateEdge(distributorNode.Id, node.Id)),
|
||||
.. agentNodes.Select(node => CreateEdge(node.Id, collectorNode.Id)),
|
||||
CreateEdge(collectorNode.Id, outputNode.Id)
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private static PatternGraphDto CreateHandoffGraph(IReadOnlyList<PatternAgentDefinitionDto> agents)
|
||||
{
|
||||
PatternGraphNodeDto inputNode = CreateSystemNode("system-user-input", UserInputKind, 0, 0);
|
||||
PatternGraphNodeDto outputNode = CreateSystemNode("system-user-output", UserOutputKind, 860, 0);
|
||||
PatternAgentDefinitionDto? entryAgent = agents.FirstOrDefault();
|
||||
PatternGraphNodeDto? entryNode = entryAgent is null
|
||||
? null
|
||||
: CreateAgentNode(entryAgent, 0, 220, 0);
|
||||
List<PatternGraphNodeDto> specialistNodes = agents
|
||||
.Skip(1)
|
||||
.Select((agent, index) => CreateAgentNode(agent, index + 1, 540, SpreadY(index, Math.Max(agents.Count - 1, 1), 220)))
|
||||
.ToList();
|
||||
|
||||
List<PatternGraphEdgeDto> edges = [];
|
||||
if (entryNode is not null)
|
||||
{
|
||||
edges.Add(CreateEdge(inputNode.Id, entryNode.Id));
|
||||
edges.Add(CreateEdge(entryNode.Id, outputNode.Id));
|
||||
|
||||
foreach (PatternGraphNodeDto specialistNode in specialistNodes)
|
||||
{
|
||||
edges.Add(CreateEdge(entryNode.Id, specialistNode.Id));
|
||||
edges.Add(CreateEdge(specialistNode.Id, entryNode.Id));
|
||||
edges.Add(CreateEdge(specialistNode.Id, outputNode.Id));
|
||||
}
|
||||
}
|
||||
|
||||
List<PatternGraphNodeDto> nodes = [inputNode];
|
||||
if (entryNode is not null)
|
||||
{
|
||||
nodes.Add(entryNode);
|
||||
}
|
||||
nodes.AddRange(specialistNodes);
|
||||
nodes.Add(outputNode);
|
||||
|
||||
return new PatternGraphDto
|
||||
{
|
||||
Nodes = nodes,
|
||||
Edges = edges
|
||||
};
|
||||
}
|
||||
|
||||
private static PatternGraphDto CreateGroupChatGraph(IReadOnlyList<PatternAgentDefinitionDto> agents)
|
||||
{
|
||||
PatternGraphNodeDto inputNode = CreateSystemNode("system-user-input", UserInputKind, 0, 0);
|
||||
PatternGraphNodeDto orchestratorNode = CreateSystemNode("system-orchestrator", OrchestratorKind, 250, 0);
|
||||
PatternGraphNodeDto outputNode = CreateSystemNode("system-user-output", UserOutputKind, 900, 0);
|
||||
const double centerX = 560;
|
||||
const double centerY = 0;
|
||||
const double radiusX = 190;
|
||||
const double radiusY = 170;
|
||||
|
||||
List<PatternGraphNodeDto> agentNodes = agents
|
||||
.Select((agent, index) =>
|
||||
{
|
||||
double angle = agents.Count <= 1
|
||||
? 0
|
||||
: (Math.PI * 2 * index) / agents.Count - (Math.PI / 2);
|
||||
return CreateAgentNode(
|
||||
agent,
|
||||
index,
|
||||
Math.Round(centerX + Math.Cos(angle) * radiusX),
|
||||
Math.Round(centerY + Math.Sin(angle) * radiusY));
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new PatternGraphDto
|
||||
{
|
||||
Nodes = [inputNode, orchestratorNode, .. agentNodes, outputNode],
|
||||
Edges =
|
||||
[
|
||||
CreateEdge(inputNode.Id, orchestratorNode.Id),
|
||||
.. agentNodes.SelectMany(node => new[]
|
||||
{
|
||||
CreateEdge(orchestratorNode.Id, node.Id),
|
||||
CreateEdge(node.Id, orchestratorNode.Id)
|
||||
}),
|
||||
CreateEdge(orchestratorNode.Id, outputNode.Id)
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private static PatternGraphNodeDto CreateSystemNode(string id, string kind, double x, double y)
|
||||
=> new()
|
||||
{
|
||||
Id = id,
|
||||
Kind = kind,
|
||||
Position = new PatternGraphPositionDto
|
||||
{
|
||||
X = x,
|
||||
Y = y
|
||||
}
|
||||
};
|
||||
|
||||
private static PatternGraphNodeDto CreateAgentNode(PatternAgentDefinitionDto agent, int order, double x, double y)
|
||||
=> new()
|
||||
{
|
||||
Id = $"agent-node-{agent.Id}",
|
||||
Kind = AgentKind,
|
||||
AgentId = agent.Id,
|
||||
Order = order,
|
||||
Position = new PatternGraphPositionDto
|
||||
{
|
||||
X = x,
|
||||
Y = y
|
||||
}
|
||||
};
|
||||
|
||||
private static PatternGraphEdgeDto CreateEdge(string source, string target)
|
||||
=> new()
|
||||
{
|
||||
Id = $"edge-{source}-to-{target}",
|
||||
Source = source,
|
||||
Target = target
|
||||
};
|
||||
|
||||
private static double SpreadY(int index, int count, double gap)
|
||||
=> (index - ((count - 1) / 2d)) * gap;
|
||||
}
|
||||
@@ -1,573 +0,0 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
public sealed class PatternValidator
|
||||
{
|
||||
private static readonly StringComparer Comparer = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
public IReadOnlyList<PatternValidationIssueDto> Validate(PatternDefinitionDto pattern)
|
||||
{
|
||||
List<PatternValidationIssueDto> issues = [];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pattern.Name))
|
||||
{
|
||||
issues.Add(new PatternValidationIssueDto
|
||||
{
|
||||
Field = "name",
|
||||
Message = "Pattern name is required.",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.Equals(pattern.Availability, "unavailable", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
issues.Add(new PatternValidationIssueDto
|
||||
{
|
||||
Field = "availability",
|
||||
Message = pattern.UnavailabilityReason ?? "This orchestration mode is currently unavailable.",
|
||||
});
|
||||
}
|
||||
|
||||
if (pattern.Agents.Count == 0)
|
||||
{
|
||||
issues.Add(new PatternValidationIssueDto
|
||||
{
|
||||
Field = "agents",
|
||||
Message = "At least one agent is required.",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.Equals(pattern.Mode, "single", StringComparison.OrdinalIgnoreCase) && pattern.Agents.Count != 1)
|
||||
{
|
||||
issues.Add(new PatternValidationIssueDto
|
||||
{
|
||||
Field = "agents",
|
||||
Message = "Single-agent chat requires exactly one agent.",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.Equals(pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase) && pattern.Agents.Count < 2)
|
||||
{
|
||||
issues.Add(new PatternValidationIssueDto
|
||||
{
|
||||
Field = "agents",
|
||||
Message = "Handoff orchestration requires at least two agents.",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.Equals(pattern.Mode, "group-chat", StringComparison.OrdinalIgnoreCase) && pattern.Agents.Count < 2)
|
||||
{
|
||||
issues.Add(new PatternValidationIssueDto
|
||||
{
|
||||
Field = "agents",
|
||||
Message = "Group chat requires at least two agents.",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.Equals(pattern.Mode, "magentic", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
issues.Add(new PatternValidationIssueDto
|
||||
{
|
||||
Field = "mode",
|
||||
Message = pattern.UnavailabilityReason
|
||||
?? "Magentic orchestration is currently documented as unsupported in the .NET Agent Framework.",
|
||||
});
|
||||
}
|
||||
|
||||
foreach (PatternAgentDefinitionDto agent in pattern.Agents)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(agent.Name))
|
||||
{
|
||||
issues.Add(new PatternValidationIssueDto
|
||||
{
|
||||
Field = "agents.name",
|
||||
Message = "Every agent needs a name.",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(agent.Model))
|
||||
{
|
||||
issues.Add(new PatternValidationIssueDto
|
||||
{
|
||||
Field = "agents.model",
|
||||
Message = $"Agent \"{agent.Name}\" requires a model identifier.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ValidateGraph(pattern, PatternGraphResolver.Resolve(pattern), issues);
|
||||
return issues;
|
||||
}
|
||||
|
||||
private static void ValidateGraph(
|
||||
PatternDefinitionDto pattern,
|
||||
PatternGraphDto graph,
|
||||
List<PatternValidationIssueDto> issues)
|
||||
{
|
||||
if (graph.Nodes.Count == 0)
|
||||
{
|
||||
AddGraphIssue(issues, "Pattern graph must include nodes.");
|
||||
return;
|
||||
}
|
||||
|
||||
HashSet<string> nodeIds = new(StringComparer.Ordinal);
|
||||
HashSet<string> edgeIds = new(StringComparer.Ordinal);
|
||||
HashSet<string> agentIds = pattern.Agents.Select(agent => agent.Id).ToHashSet(StringComparer.Ordinal);
|
||||
HashSet<string> seenAgentIds = new(StringComparer.Ordinal);
|
||||
HashSet<int> seenAgentOrders = [];
|
||||
Dictionary<string, PatternGraphNodeDto> nodesById = new(StringComparer.Ordinal);
|
||||
|
||||
foreach (PatternGraphNodeDto node in graph.Nodes)
|
||||
{
|
||||
if (!nodeIds.Add(node.Id))
|
||||
{
|
||||
AddGraphIssue(issues, $"Pattern graph contains duplicate node \"{node.Id}\".");
|
||||
}
|
||||
|
||||
nodesById[node.Id] = node;
|
||||
|
||||
if (Comparer.Equals(node.Kind, "agent"))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(node.AgentId) || !agentIds.Contains(node.AgentId))
|
||||
{
|
||||
AddGraphIssue(issues, $"Agent node \"{node.Id}\" must reference a known agent.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(node.AgentId) && !seenAgentIds.Add(node.AgentId))
|
||||
{
|
||||
AddGraphIssue(issues, $"Pattern graph contains multiple nodes for agent \"{node.AgentId}\".");
|
||||
}
|
||||
|
||||
if (!node.Order.HasValue)
|
||||
{
|
||||
AddGraphIssue(issues, $"Agent node \"{node.Id}\" must define an order.");
|
||||
}
|
||||
else if (!seenAgentOrders.Add(node.Order.Value))
|
||||
{
|
||||
AddGraphIssue(issues, $"Pattern graph contains duplicate agent order \"{node.Order.Value}\".");
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(node.AgentId))
|
||||
{
|
||||
AddGraphIssue(issues, $"System node \"{node.Id}\" cannot reference an agent.");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (PatternAgentDefinitionDto agent in pattern.Agents)
|
||||
{
|
||||
if (!seenAgentIds.Contains(agent.Id))
|
||||
{
|
||||
AddGraphIssue(issues, $"Pattern graph is missing node metadata for agent \"{agent.Id}\".");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (PatternGraphEdgeDto edge in graph.Edges)
|
||||
{
|
||||
if (!edgeIds.Add(edge.Id))
|
||||
{
|
||||
AddGraphIssue(issues, $"Pattern graph contains duplicate edge \"{edge.Id}\".");
|
||||
}
|
||||
|
||||
if (!nodesById.ContainsKey(edge.Source) || !nodesById.ContainsKey(edge.Target))
|
||||
{
|
||||
AddGraphIssue(issues, $"Pattern graph edge \"{edge.Id}\" must connect known nodes.");
|
||||
}
|
||||
}
|
||||
|
||||
switch (pattern.Mode)
|
||||
{
|
||||
case "single":
|
||||
case "sequential":
|
||||
case "magentic":
|
||||
ValidateLinearGraph(pattern, graph, issues);
|
||||
break;
|
||||
case "concurrent":
|
||||
ValidateConcurrentGraph(pattern, graph, issues);
|
||||
break;
|
||||
case "handoff":
|
||||
ValidateHandoffGraph(graph, issues);
|
||||
break;
|
||||
case "group-chat":
|
||||
ValidateGroupChatGraph(pattern, graph, issues);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateLinearGraph(
|
||||
PatternDefinitionDto pattern,
|
||||
PatternGraphDto graph,
|
||||
List<PatternValidationIssueDto> issues)
|
||||
{
|
||||
ValidateSystemNodeCounts(graph, ["user-input", "user-output"], issues);
|
||||
PatternGraphNodeDto? inputNode = GetNodeByKind(graph, "user-input");
|
||||
PatternGraphNodeDto? outputNode = GetNodeByKind(graph, "user-output");
|
||||
if (inputNode is null || outputNode is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dictionary<string, List<PatternGraphEdgeDto>> incoming = BuildIncomingLookup(graph);
|
||||
Dictionary<string, List<PatternGraphEdgeDto>> outgoing = BuildOutgoingLookup(graph);
|
||||
List<PatternGraphNodeDto> agentNodes = GetAgentNodes(graph);
|
||||
|
||||
if (graph.Edges.Count != pattern.Agents.Count + 1)
|
||||
{
|
||||
AddGraphIssue(issues, "Linear orchestration graphs must be a single path from user input through every agent to user output.");
|
||||
}
|
||||
|
||||
if (incoming.GetValueOrDefault(inputNode.Id, []).Count != 0 || outgoing.GetValueOrDefault(inputNode.Id, []).Count != 1)
|
||||
{
|
||||
AddGraphIssue(issues, "User input must start exactly one path.");
|
||||
}
|
||||
|
||||
if (incoming.GetValueOrDefault(outputNode.Id, []).Count != 1 || outgoing.GetValueOrDefault(outputNode.Id, []).Count != 0)
|
||||
{
|
||||
AddGraphIssue(issues, "User output must terminate exactly one path.");
|
||||
}
|
||||
|
||||
foreach (PatternGraphNodeDto node in agentNodes)
|
||||
{
|
||||
if (incoming.GetValueOrDefault(node.Id, []).Count != 1 || outgoing.GetValueOrDefault(node.Id, []).Count != 1)
|
||||
{
|
||||
AddGraphIssue(issues, "Each agent in a linear orchestration must have exactly one incoming and one outgoing edge.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
HashSet<string> visited = new(StringComparer.Ordinal);
|
||||
string currentNodeId = inputNode.Id;
|
||||
while (visited.Add(currentNodeId))
|
||||
{
|
||||
List<PatternGraphEdgeDto> nextEdges = outgoing.GetValueOrDefault(currentNodeId, []);
|
||||
if (nextEdges.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (nextEdges.Count != 1)
|
||||
{
|
||||
AddGraphIssue(issues, "Linear orchestration nodes may only branch to one next step.");
|
||||
break;
|
||||
}
|
||||
|
||||
currentNodeId = nextEdges[0].Target;
|
||||
if (Comparer.Equals(currentNodeId, outputNode.Id))
|
||||
{
|
||||
visited.Add(currentNodeId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
HashSet<string> expectedVisited = new(StringComparer.Ordinal)
|
||||
{
|
||||
inputNode.Id,
|
||||
outputNode.Id
|
||||
};
|
||||
foreach (PatternGraphNodeDto node in agentNodes)
|
||||
{
|
||||
expectedVisited.Add(node.Id);
|
||||
}
|
||||
|
||||
if (!expectedVisited.SetEquals(visited))
|
||||
{
|
||||
AddGraphIssue(issues, "Linear orchestration graphs must visit every agent exactly once.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateConcurrentGraph(
|
||||
PatternDefinitionDto pattern,
|
||||
PatternGraphDto graph,
|
||||
List<PatternValidationIssueDto> issues)
|
||||
{
|
||||
ValidateSystemNodeCounts(graph, ["user-input", "distributor", "collector", "user-output"], issues);
|
||||
PatternGraphNodeDto? inputNode = GetNodeByKind(graph, "user-input");
|
||||
PatternGraphNodeDto? distributorNode = GetNodeByKind(graph, "distributor");
|
||||
PatternGraphNodeDto? collectorNode = GetNodeByKind(graph, "collector");
|
||||
PatternGraphNodeDto? outputNode = GetNodeByKind(graph, "user-output");
|
||||
if (inputNode is null || distributorNode is null || collectorNode is null || outputNode is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dictionary<string, List<PatternGraphEdgeDto>> incoming = BuildIncomingLookup(graph);
|
||||
Dictionary<string, List<PatternGraphEdgeDto>> outgoing = BuildOutgoingLookup(graph);
|
||||
List<PatternGraphNodeDto> agentNodes = GetAgentNodes(graph);
|
||||
HashSet<string> distributorTargets = outgoing.GetValueOrDefault(distributorNode.Id, []).Select(edge => edge.Target).ToHashSet(StringComparer.Ordinal);
|
||||
HashSet<string> collectorSources = incoming.GetValueOrDefault(collectorNode.Id, []).Select(edge => edge.Source).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
if (graph.Edges.Count != pattern.Agents.Count * 2 + 2)
|
||||
{
|
||||
AddGraphIssue(issues, "Concurrent orchestration graphs must fan out from the distributor and fan back into the collector.");
|
||||
}
|
||||
|
||||
if (incoming.GetValueOrDefault(inputNode.Id, []).Count != 0 || outgoing.GetValueOrDefault(inputNode.Id, []).Count != 1)
|
||||
{
|
||||
AddGraphIssue(issues, "User input must connect only to the distributor.");
|
||||
}
|
||||
|
||||
if (incoming.GetValueOrDefault(distributorNode.Id, []).Count != 1)
|
||||
{
|
||||
AddGraphIssue(issues, "Distributor must receive exactly one edge from user input.");
|
||||
}
|
||||
|
||||
if (outgoing.GetValueOrDefault(collectorNode.Id, []).Count != 1 || incoming.GetValueOrDefault(outputNode.Id, []).Count != 1)
|
||||
{
|
||||
AddGraphIssue(issues, "Collector must forward exactly one edge to user output.");
|
||||
}
|
||||
|
||||
foreach (PatternGraphNodeDto agentNode in agentNodes)
|
||||
{
|
||||
if (!distributorTargets.Contains(agentNode.Id))
|
||||
{
|
||||
AddGraphIssue(issues, $"Distributor must connect to agent \"{agentNode.AgentId}\".");
|
||||
}
|
||||
|
||||
if (!collectorSources.Contains(agentNode.Id))
|
||||
{
|
||||
AddGraphIssue(issues, $"Agent \"{agentNode.AgentId}\" must connect to the collector.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateHandoffGraph(
|
||||
PatternGraphDto graph,
|
||||
List<PatternValidationIssueDto> issues)
|
||||
{
|
||||
ValidateSystemNodeCounts(graph, ["user-input", "user-output"], issues);
|
||||
PatternGraphNodeDto? inputNode = GetNodeByKind(graph, "user-input");
|
||||
PatternGraphNodeDto? outputNode = GetNodeByKind(graph, "user-output");
|
||||
if (inputNode is null || outputNode is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dictionary<string, List<PatternGraphEdgeDto>> incoming = BuildIncomingLookup(graph);
|
||||
Dictionary<string, List<PatternGraphEdgeDto>> outgoing = BuildOutgoingLookup(graph);
|
||||
List<PatternGraphNodeDto> agentNodes = GetAgentNodes(graph);
|
||||
HashSet<string> agentNodeIds = agentNodes.Select(node => node.Id).ToHashSet(StringComparer.Ordinal);
|
||||
List<PatternGraphEdgeDto> entryEdges = outgoing.GetValueOrDefault(inputNode.Id, []);
|
||||
List<PatternGraphEdgeDto> completionEdges = incoming.GetValueOrDefault(outputNode.Id, []);
|
||||
|
||||
if (entryEdges.Count != 1)
|
||||
{
|
||||
AddGraphIssue(issues, "Handoff graphs must connect user input to exactly one entry agent.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!agentNodeIds.Contains(entryEdges[0].Target))
|
||||
{
|
||||
AddGraphIssue(issues, "Handoff entry edges must target an agent node.");
|
||||
}
|
||||
|
||||
if (completionEdges.Count == 0)
|
||||
{
|
||||
AddGraphIssue(issues, "Handoff graphs must allow at least one agent to complete back to user output.");
|
||||
}
|
||||
|
||||
bool hasAgentToAgentRoute = false;
|
||||
foreach (PatternGraphEdgeDto edge in graph.Edges)
|
||||
{
|
||||
if (Comparer.Equals(edge.Source, inputNode.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Comparer.Equals(edge.Target, outputNode.Id))
|
||||
{
|
||||
if (!agentNodeIds.Contains(edge.Source))
|
||||
{
|
||||
AddGraphIssue(issues, "Only agent nodes may complete to user output.");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!agentNodeIds.Contains(edge.Source) || !agentNodeIds.Contains(edge.Target))
|
||||
{
|
||||
AddGraphIssue(issues, "Handoff routes may only connect agents to agents or agents to user output.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Comparer.Equals(edge.Source, edge.Target))
|
||||
{
|
||||
AddGraphIssue(issues, "Handoff routes cannot target the same agent node.");
|
||||
}
|
||||
|
||||
hasAgentToAgentRoute = true;
|
||||
}
|
||||
|
||||
if (!hasAgentToAgentRoute && agentNodes.Count > 1)
|
||||
{
|
||||
AddGraphIssue(issues, "Handoff graphs must include at least one agent-to-agent handoff route.");
|
||||
}
|
||||
|
||||
HashSet<string> reachable = new(StringComparer.Ordinal);
|
||||
Stack<string> stack = new Stack<string>([entryEdges[0].Target]);
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
string nodeId = stack.Pop();
|
||||
if (!reachable.Add(nodeId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (PatternGraphEdgeDto edge in outgoing.GetValueOrDefault(nodeId, []))
|
||||
{
|
||||
if (agentNodeIds.Contains(edge.Target) && !reachable.Contains(edge.Target))
|
||||
{
|
||||
stack.Push(edge.Target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (PatternGraphNodeDto agentNode in agentNodes)
|
||||
{
|
||||
if (!reachable.Contains(agentNode.Id))
|
||||
{
|
||||
AddGraphIssue(issues, $"Handoff entry agent must be able to reach \"{agentNode.AgentId}\".");
|
||||
}
|
||||
}
|
||||
|
||||
if (incoming.GetValueOrDefault(inputNode.Id, []).Count != 0 || outgoing.GetValueOrDefault(outputNode.Id, []).Count != 0)
|
||||
{
|
||||
AddGraphIssue(issues, "User input cannot have incoming edges and user output cannot have outgoing edges.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateGroupChatGraph(
|
||||
PatternDefinitionDto pattern,
|
||||
PatternGraphDto graph,
|
||||
List<PatternValidationIssueDto> issues)
|
||||
{
|
||||
ValidateSystemNodeCounts(graph, ["user-input", "orchestrator", "user-output"], issues);
|
||||
PatternGraphNodeDto? inputNode = GetNodeByKind(graph, "user-input");
|
||||
PatternGraphNodeDto? orchestratorNode = GetNodeByKind(graph, "orchestrator");
|
||||
PatternGraphNodeDto? outputNode = GetNodeByKind(graph, "user-output");
|
||||
if (inputNode is null || orchestratorNode is null || outputNode is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dictionary<string, List<PatternGraphEdgeDto>> incoming = BuildIncomingLookup(graph);
|
||||
Dictionary<string, List<PatternGraphEdgeDto>> outgoing = BuildOutgoingLookup(graph);
|
||||
List<PatternGraphNodeDto> agentNodes = GetAgentNodes(graph);
|
||||
HashSet<string> orchestratorTargets = outgoing.GetValueOrDefault(orchestratorNode.Id, []).Select(edge => edge.Target).ToHashSet(StringComparer.Ordinal);
|
||||
HashSet<string> orchestratorSources = incoming.GetValueOrDefault(orchestratorNode.Id, []).Select(edge => edge.Source).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
if (graph.Edges.Count != pattern.Agents.Count * 2 + 2)
|
||||
{
|
||||
AddGraphIssue(issues, "Group chat graphs must connect the orchestrator to every participant and then back to user output.");
|
||||
}
|
||||
|
||||
if (outgoing.GetValueOrDefault(inputNode.Id, []).Any(edge => !Comparer.Equals(edge.Target, orchestratorNode.Id)))
|
||||
{
|
||||
AddGraphIssue(issues, "User input must only connect to the orchestrator.");
|
||||
}
|
||||
|
||||
if (!outgoing.GetValueOrDefault(orchestratorNode.Id, []).Any(edge => Comparer.Equals(edge.Target, outputNode.Id)))
|
||||
{
|
||||
AddGraphIssue(issues, "Group chat orchestrator must connect to user output.");
|
||||
}
|
||||
|
||||
foreach (PatternGraphNodeDto agentNode in agentNodes)
|
||||
{
|
||||
if (!orchestratorTargets.Contains(agentNode.Id))
|
||||
{
|
||||
AddGraphIssue(issues, $"Orchestrator must connect to agent \"{agentNode.AgentId}\".");
|
||||
}
|
||||
|
||||
if (!orchestratorSources.Contains(agentNode.Id))
|
||||
{
|
||||
AddGraphIssue(issues, $"Agent \"{agentNode.AgentId}\" must connect back to the orchestrator.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateSystemNodeCounts(
|
||||
PatternGraphDto graph,
|
||||
IReadOnlyList<string> expectedKinds,
|
||||
List<PatternValidationIssueDto> issues)
|
||||
{
|
||||
Dictionary<string, int> counts = graph.Nodes
|
||||
.GroupBy(node => node.Kind, Comparer)
|
||||
.ToDictionary(group => group.Key, group => group.Count(), Comparer);
|
||||
HashSet<string> expected = expectedKinds.ToHashSet(Comparer);
|
||||
|
||||
foreach (string kind in expectedKinds)
|
||||
{
|
||||
if (counts.GetValueOrDefault(kind, 0) != 1)
|
||||
{
|
||||
AddGraphIssue(issues, $"Pattern graph must include exactly one \"{kind}\" node.");
|
||||
}
|
||||
}
|
||||
|
||||
foreach ((string kind, int count) in counts)
|
||||
{
|
||||
if (Comparer.Equals(kind, "agent"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!expected.Contains(kind) && count > 0)
|
||||
{
|
||||
AddGraphIssue(issues, $"Pattern graph does not allow \"{kind}\" nodes in this mode.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static PatternGraphNodeDto? GetNodeByKind(PatternGraphDto graph, string kind)
|
||||
=> graph.Nodes.FirstOrDefault(node => Comparer.Equals(node.Kind, kind));
|
||||
|
||||
private static List<PatternGraphNodeDto> GetAgentNodes(PatternGraphDto graph)
|
||||
=> graph.Nodes.Where(node => Comparer.Equals(node.Kind, "agent")).ToList();
|
||||
|
||||
private static Dictionary<string, List<PatternGraphEdgeDto>> BuildIncomingLookup(PatternGraphDto graph)
|
||||
{
|
||||
Dictionary<string, List<PatternGraphEdgeDto>> incoming = new(StringComparer.Ordinal);
|
||||
foreach (PatternGraphNodeDto node in graph.Nodes)
|
||||
{
|
||||
incoming[node.Id] = [];
|
||||
}
|
||||
|
||||
foreach (PatternGraphEdgeDto edge in graph.Edges)
|
||||
{
|
||||
if (!incoming.TryGetValue(edge.Target, out List<PatternGraphEdgeDto>? edges))
|
||||
{
|
||||
edges = [];
|
||||
incoming[edge.Target] = edges;
|
||||
}
|
||||
|
||||
edges.Add(edge);
|
||||
}
|
||||
|
||||
return incoming;
|
||||
}
|
||||
|
||||
private static Dictionary<string, List<PatternGraphEdgeDto>> BuildOutgoingLookup(PatternGraphDto graph)
|
||||
{
|
||||
Dictionary<string, List<PatternGraphEdgeDto>> outgoing = new(StringComparer.Ordinal);
|
||||
foreach (PatternGraphNodeDto node in graph.Nodes)
|
||||
{
|
||||
outgoing[node.Id] = [];
|
||||
}
|
||||
|
||||
foreach (PatternGraphEdgeDto edge in graph.Edges)
|
||||
{
|
||||
if (!outgoing.TryGetValue(edge.Source, out List<PatternGraphEdgeDto>? edges))
|
||||
{
|
||||
edges = [];
|
||||
outgoing[edge.Source] = edges;
|
||||
}
|
||||
|
||||
edges.Add(edge);
|
||||
}
|
||||
|
||||
return outgoing;
|
||||
}
|
||||
|
||||
private static void AddGraphIssue(List<PatternValidationIssueDto> issues, string message)
|
||||
=> issues.Add(new PatternValidationIssueDto
|
||||
{
|
||||
Field = "graph",
|
||||
Message = message,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal abstract class ProviderAgentBundle : IAsyncDisposable
|
||||
{
|
||||
public abstract IReadOnlyList<AIAgent> Agents { get; }
|
||||
|
||||
public abstract bool HasConfiguredHooks { get; }
|
||||
|
||||
public abstract IProviderTranscriptProjector TranscriptProjector { get; }
|
||||
|
||||
public abstract ValueTask DisposeAsync();
|
||||
}
|
||||
@@ -0,0 +1,731 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.Channels;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
|
||||
{
|
||||
private const string DefaultName = "GitHub Copilot Agent";
|
||||
private const string DefaultDescription = "An AI agent powered by GitHub Copilot";
|
||||
private const string HandoffToolPrefix = "handoff_to_";
|
||||
private static readonly JsonSerializerOptions ToolArgumentJsonOptions = JsonSerialization.CreateWebOptions();
|
||||
private readonly CopilotClient _copilotClient;
|
||||
private readonly string? _id;
|
||||
private readonly string _name;
|
||||
private readonly string _description;
|
||||
private readonly SessionConfig? _sessionConfig;
|
||||
private readonly bool _ownsClient;
|
||||
|
||||
public AryxCopilotAgent(
|
||||
CopilotClient copilotClient,
|
||||
SessionConfig? sessionConfig = null,
|
||||
bool ownsClient = false,
|
||||
string? id = null,
|
||||
string? name = null,
|
||||
string? description = null)
|
||||
{
|
||||
_copilotClient = copilotClient ?? throw new ArgumentNullException(nameof(copilotClient));
|
||||
_sessionConfig = sessionConfig;
|
||||
_ownsClient = ownsClient;
|
||||
_id = id;
|
||||
_name = name ?? DefaultName;
|
||||
_description = description ?? DefaultDescription;
|
||||
}
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
=> new(new AryxCopilotAgentSession());
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
|
||||
AgentSession session,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (session is not AryxCopilotAgentSession typedSession)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(AryxCopilotAgentSession)}' can be serialized by this agent.");
|
||||
}
|
||||
|
||||
return new(typedSession.Serialize(jsonSerializerOptions));
|
||||
}
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> new(AryxCopilotAgentSession.Deserialize(serializedState, jsonSerializerOptions));
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> RunCoreStreamingAsync(messages, session, options, cancellationToken).ToAgentResponseAsync(cancellationToken);
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(messages);
|
||||
|
||||
session ??= await CreateSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (session is not AryxCopilotAgentSession typedSession)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(AryxCopilotAgentSession)}' can be used by this agent.");
|
||||
}
|
||||
|
||||
await EnsureClientStartedAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
SessionConfig sessionConfig = CreateConfiguredSessionConfig(_sessionConfig, options);
|
||||
CopilotSession copilotSession;
|
||||
if (typedSession.SessionId is not null)
|
||||
{
|
||||
copilotSession = await _copilotClient.ResumeSessionAsync(
|
||||
typedSession.SessionId,
|
||||
CreateResumeConfig(sessionConfig),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
copilotSession = await _copilotClient.CreateSessionAsync(sessionConfig, cancellationToken).ConfigureAwait(false);
|
||||
typedSession.SessionId = copilotSession.SessionId;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Channel<AgentResponseUpdate> channel = Channel.CreateUnbounded<AgentResponseUpdate>();
|
||||
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
|
||||
|
||||
using IDisposable subscription = copilotSession.On(evt =>
|
||||
{
|
||||
switch (evt)
|
||||
{
|
||||
case AssistantMessageDeltaEvent deltaEvent:
|
||||
channel.Writer.TryWrite(ConvertToAgentResponseUpdate(deltaEvent));
|
||||
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;
|
||||
|
||||
case SessionIdleEvent idleEvent:
|
||||
channel.Writer.TryWrite(ConvertToAgentResponseUpdate(idleEvent));
|
||||
channel.Writer.TryComplete();
|
||||
break;
|
||||
|
||||
case SessionErrorEvent errorEvent:
|
||||
channel.Writer.TryWrite(ConvertToAgentResponseUpdate(errorEvent));
|
||||
channel.Writer.TryComplete(new InvalidOperationException(
|
||||
$"Session error: {errorEvent.Data?.Message ?? "Unknown error"}"));
|
||||
break;
|
||||
|
||||
default:
|
||||
channel.Writer.TryWrite(ConvertToAgentResponseUpdate(evt));
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
string? tempDir = null;
|
||||
try
|
||||
{
|
||||
string prompt = string.Join("\n", messages.Select(message => message.Text));
|
||||
(List<UserMessageDataAttachmentsItem>? attachments, string? messageMode, tempDir) = await ProcessMessageAttachmentsAsync(
|
||||
messages,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
MessageOptions messageOptions = new()
|
||||
{
|
||||
Prompt = prompt,
|
||||
Mode = string.IsNullOrWhiteSpace(messageMode) ? null : messageMode,
|
||||
};
|
||||
|
||||
if (attachments is not null)
|
||||
{
|
||||
messageOptions.Attachments = [.. attachments];
|
||||
}
|
||||
|
||||
await copilotSession.SendAsync(messageOptions, cancellationToken).ConfigureAwait(false);
|
||||
await foreach (AgentResponseUpdate update in channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
CleanupTempDir(tempDir);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await copilotSession.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected override string? IdCore => _id;
|
||||
|
||||
public override string Name => _name;
|
||||
|
||||
public override string Description => _description;
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_ownsClient)
|
||||
{
|
||||
await _copilotClient.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
internal static SessionConfig CreateConfiguredSessionConfig(SessionConfig? source, AgentRunOptions? options)
|
||||
{
|
||||
SessionConfig sessionConfig = source?.Clone() ?? new SessionConfig();
|
||||
sessionConfig.Streaming = true;
|
||||
if (sessionConfig.SystemMessage is not null)
|
||||
{
|
||||
sessionConfig.SystemMessage = CloneSystemMessage(sessionConfig.SystemMessage);
|
||||
}
|
||||
|
||||
if (options is not ChatClientAgentRunOptions { ChatOptions: { } chatOptions })
|
||||
{
|
||||
return sessionConfig;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(chatOptions.ModelId))
|
||||
{
|
||||
sessionConfig.Model = chatOptions.ModelId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(chatOptions.Instructions))
|
||||
{
|
||||
AppendInstructions(sessionConfig, chatOptions.Instructions);
|
||||
}
|
||||
|
||||
sessionConfig.Tools = MergeTools(sessionConfig.Tools, chatOptions.Tools);
|
||||
return sessionConfig;
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<FunctionCallContent> ConvertToolRequestsToFunctionCalls(
|
||||
AssistantMessageDataToolRequestsItem[]? toolRequests)
|
||||
{
|
||||
if (toolRequests is not { Length: > 0 })
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
List<FunctionCallContent> contents = [];
|
||||
foreach (AssistantMessageDataToolRequestsItem toolRequest in toolRequests)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(toolRequest.ToolCallId) || string.IsNullOrWhiteSpace(toolRequest.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
contents.Add(new FunctionCallContent(
|
||||
toolRequest.ToolCallId,
|
||||
toolRequest.Name,
|
||||
ParseToolArguments(toolRequest.Arguments)));
|
||||
}
|
||||
|
||||
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)
|
||||
&& name.StartsWith(HandoffToolPrefix, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private async Task EnsureClientStartedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_copilotClient.State != ConnectionState.Connected)
|
||||
{
|
||||
await _copilotClient.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static ResumeSessionConfig CreateResumeConfig(SessionConfig source)
|
||||
{
|
||||
return new ResumeSessionConfig
|
||||
{
|
||||
ClientName = source.ClientName,
|
||||
Model = source.Model,
|
||||
Tools = source.Tools is not null ? [.. source.Tools] : null,
|
||||
SystemMessage = CloneSystemMessage(source.SystemMessage),
|
||||
AvailableTools = source.AvailableTools is not null ? [.. source.AvailableTools] : null,
|
||||
ExcludedTools = source.ExcludedTools is not null ? [.. source.ExcludedTools] : null,
|
||||
Provider = source.Provider,
|
||||
OnPermissionRequest = source.OnPermissionRequest,
|
||||
OnUserInputRequest = source.OnUserInputRequest,
|
||||
Hooks = source.Hooks,
|
||||
WorkingDirectory = source.WorkingDirectory,
|
||||
ConfigDir = source.ConfigDir,
|
||||
Streaming = true,
|
||||
McpServers = source.McpServers is not null
|
||||
? new Dictionary<string, object>(source.McpServers, source.McpServers.Comparer)
|
||||
: null,
|
||||
CustomAgents = source.CustomAgents is not null ? [.. source.CustomAgents] : null,
|
||||
Agent = source.Agent,
|
||||
SkillDirectories = source.SkillDirectories is not null ? [.. source.SkillDirectories] : null,
|
||||
DisabledSkills = source.DisabledSkills is not null ? [.. source.DisabledSkills] : null,
|
||||
InfiniteSessions = source.InfiniteSessions,
|
||||
OnEvent = source.OnEvent,
|
||||
ReasoningEffort = source.ReasoningEffort,
|
||||
};
|
||||
}
|
||||
|
||||
private static SystemMessageConfig? CloneSystemMessage(SystemMessageConfig? source)
|
||||
{
|
||||
if (source is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SystemMessageConfig
|
||||
{
|
||||
Mode = source.Mode,
|
||||
Content = source.Content,
|
||||
Sections = source.Sections is not null ? new Dictionary<string, SectionOverride>(source.Sections) : null,
|
||||
};
|
||||
}
|
||||
|
||||
private static void AppendInstructions(SessionConfig sessionConfig, string instructions)
|
||||
{
|
||||
string trimmedInstructions = instructions.Trim();
|
||||
if (trimmedInstructions.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionConfig.SystemMessage is null)
|
||||
{
|
||||
sessionConfig.SystemMessage = new SystemMessageConfig
|
||||
{
|
||||
Mode = SystemMessageMode.Append,
|
||||
Content = trimmedInstructions,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
string? existingContent = sessionConfig.SystemMessage.Content;
|
||||
sessionConfig.SystemMessage.Content = string.IsNullOrWhiteSpace(existingContent)
|
||||
? trimmedInstructions
|
||||
: $"{existingContent.Trim()}\n\n{trimmedInstructions}";
|
||||
}
|
||||
|
||||
private static ICollection<AIFunction>? MergeTools(
|
||||
ICollection<AIFunction>? sessionTools,
|
||||
IList<AITool>? runtimeTools)
|
||||
{
|
||||
if (runtimeTools is not { Count: > 0 })
|
||||
{
|
||||
return sessionTools;
|
||||
}
|
||||
|
||||
List<AIFunction> mergedTools = sessionTools is not null ? [.. sessionTools] : [];
|
||||
foreach (AITool runtimeTool in runtimeTools)
|
||||
{
|
||||
mergedTools.Add(MapRuntimeTool(runtimeTool));
|
||||
}
|
||||
|
||||
return mergedTools;
|
||||
}
|
||||
|
||||
private static AIFunction MapRuntimeTool(AITool tool)
|
||||
{
|
||||
return tool switch
|
||||
{
|
||||
AIFunction function => function,
|
||||
AIFunctionDeclaration declaration when IsHandoffDeclaration(declaration) => CreateInvokableHandoffFunction(declaration),
|
||||
AIFunctionDeclaration declaration => throw new NotSupportedException(
|
||||
$"GitHub Copilot session tools must be invokable AIFunctions. Runtime tool '{declaration.Name}' is declaration-only."),
|
||||
_ => throw new NotSupportedException(
|
||||
$"GitHub Copilot session tools must be invokable AIFunctions. Runtime tool '{tool.Name}' is not supported."),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsHandoffDeclaration(AIFunctionDeclaration declaration)
|
||||
{
|
||||
return IsHandoffToolName(declaration.Name);
|
||||
}
|
||||
|
||||
private static AIFunction CreateInvokableHandoffFunction(AIFunctionDeclaration declaration)
|
||||
{
|
||||
AIFunction function = AIFunctionFactory.Create(
|
||||
(string? reasonForHandoff) => "Transferred.",
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = declaration.Name,
|
||||
Description = declaration.Description,
|
||||
AdditionalProperties = new Dictionary<string, object?>
|
||||
{
|
||||
["skip_permission"] = true,
|
||||
},
|
||||
});
|
||||
return function;
|
||||
}
|
||||
|
||||
private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageDeltaEvent deltaEvent)
|
||||
{
|
||||
TextContent textContent = new(deltaEvent.Data?.DeltaContent ?? string.Empty)
|
||||
{
|
||||
RawRepresentation = deltaEvent,
|
||||
};
|
||||
|
||||
return new AgentResponseUpdate(ChatRole.Assistant, [textContent])
|
||||
{
|
||||
AgentId = Id,
|
||||
MessageId = deltaEvent.Data?.MessageId,
|
||||
CreatedAt = deltaEvent.Timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageEvent assistantMessage)
|
||||
{
|
||||
List<AIContent> contents = [];
|
||||
contents.AddRange(ConvertToolRequestsToFunctionCalls(assistantMessage.Data?.ToolRequests));
|
||||
contents.Add(new AIContent
|
||||
{
|
||||
RawRepresentation = assistantMessage,
|
||||
});
|
||||
|
||||
return new AgentResponseUpdate(ChatRole.Assistant, contents)
|
||||
{
|
||||
AgentId = Id,
|
||||
ResponseId = assistantMessage.Data?.MessageId,
|
||||
MessageId = assistantMessage.Data?.MessageId,
|
||||
CreatedAt = assistantMessage.Timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantUsageEvent usageEvent)
|
||||
{
|
||||
UsageDetails usageDetails = new()
|
||||
{
|
||||
InputTokenCount = (int?)usageEvent.Data?.InputTokens,
|
||||
OutputTokenCount = (int?)usageEvent.Data?.OutputTokens,
|
||||
TotalTokenCount = (int?)((usageEvent.Data?.InputTokens ?? 0) + (usageEvent.Data?.OutputTokens ?? 0)),
|
||||
CachedInputTokenCount = (int?)usageEvent.Data?.CacheReadTokens,
|
||||
};
|
||||
|
||||
UsageContent usageContent = new(usageDetails)
|
||||
{
|
||||
RawRepresentation = usageEvent,
|
||||
};
|
||||
|
||||
return new AgentResponseUpdate(ChatRole.Assistant, [usageContent])
|
||||
{
|
||||
AgentId = Id,
|
||||
CreatedAt = usageEvent.Timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
RawRepresentation = sessionEvent,
|
||||
};
|
||||
|
||||
return new AgentResponseUpdate(ChatRole.Assistant, [content])
|
||||
{
|
||||
AgentId = Id,
|
||||
CreatedAt = sessionEvent.Timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (arguments is Dictionary<string, object?> dictionary)
|
||||
{
|
||||
return new Dictionary<string, object?>(dictionary, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
if (arguments is JsonElement jsonElement)
|
||||
{
|
||||
if (jsonElement.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<Dictionary<string, object?>>(jsonElement.GetRawText(), ToolArgumentJsonOptions);
|
||||
}
|
||||
|
||||
string json = JsonSerializer.Serialize(arguments, arguments.GetType(), ToolArgumentJsonOptions);
|
||||
return JsonSerializer.Deserialize<Dictionary<string, object?>>(json, ToolArgumentJsonOptions);
|
||||
}
|
||||
|
||||
internal static async Task<(List<UserMessageDataAttachmentsItem>? Attachments, string? MessageMode, string? TempDir)> ProcessMessageAttachmentsAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<UserMessageDataAttachmentsItem>? attachments = null;
|
||||
string? messageMode = null;
|
||||
string? tempDir = null;
|
||||
foreach (ChatMessage message in messages)
|
||||
{
|
||||
foreach (AIContent content in message.Contents)
|
||||
{
|
||||
if (content is DataContent dataContent)
|
||||
{
|
||||
tempDir ??= Directory.CreateDirectory(
|
||||
Path.Combine(Path.GetTempPath(), $"af_copilot_{Guid.NewGuid():N}")).FullName;
|
||||
|
||||
string tempFilePath = await dataContent.SaveToAsync(tempDir, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
attachments ??= [];
|
||||
attachments.Add(new UserMessageDataAttachmentsItemFile
|
||||
{
|
||||
Path = tempFilePath,
|
||||
DisplayName = Path.GetFileName(tempFilePath),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content.RawRepresentation is ChatMessageAttachmentDto protocolAttachment)
|
||||
{
|
||||
attachments ??= [];
|
||||
attachments.Add(CreateProtocolAttachment(protocolAttachment));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content.RawRepresentation is CopilotMessageOptionsMetadata metadata
|
||||
&& !string.IsNullOrWhiteSpace(metadata.MessageMode))
|
||||
{
|
||||
messageMode = metadata.MessageMode.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (attachments, messageMode, tempDir);
|
||||
}
|
||||
|
||||
private static UserMessageDataAttachmentsItem CreateProtocolAttachment(ChatMessageAttachmentDto attachment)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attachment);
|
||||
|
||||
return attachment.Type switch
|
||||
{
|
||||
"file" => CreateFileAttachment(attachment),
|
||||
"blob" => CreateBlobAttachment(attachment),
|
||||
_ => throw new NotSupportedException($"Unsupported attachment type '{attachment.Type}'."),
|
||||
};
|
||||
}
|
||||
|
||||
private static UserMessageDataAttachmentsItemFile CreateFileAttachment(ChatMessageAttachmentDto attachment)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attachment.Path))
|
||||
{
|
||||
throw new InvalidOperationException("File attachments require an absolute path.");
|
||||
}
|
||||
|
||||
string path = attachment.Path.Trim();
|
||||
if (!Path.IsPathRooted(path))
|
||||
{
|
||||
throw new InvalidOperationException($"File attachment path '{path}' must be absolute.");
|
||||
}
|
||||
|
||||
return new UserMessageDataAttachmentsItemFile
|
||||
{
|
||||
Path = path,
|
||||
DisplayName = string.IsNullOrWhiteSpace(attachment.DisplayName)
|
||||
? Path.GetFileName(path)
|
||||
: attachment.DisplayName.Trim(),
|
||||
};
|
||||
}
|
||||
|
||||
private static UserMessageDataAttachmentsItemBlob CreateBlobAttachment(ChatMessageAttachmentDto attachment)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attachment.Data))
|
||||
{
|
||||
throw new InvalidOperationException("Blob attachments require base64-encoded data.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(attachment.MimeType))
|
||||
{
|
||||
throw new InvalidOperationException("Blob attachments require a MIME type.");
|
||||
}
|
||||
|
||||
return new UserMessageDataAttachmentsItemBlob
|
||||
{
|
||||
Data = attachment.Data.Trim(),
|
||||
MimeType = attachment.MimeType.Trim(),
|
||||
DisplayName = string.IsNullOrWhiteSpace(attachment.DisplayName) ? null : attachment.DisplayName.Trim(),
|
||||
};
|
||||
}
|
||||
|
||||
private static void CleanupTempDir(string? tempDir)
|
||||
{
|
||||
if (tempDir is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AryxCopilotAgentSession : AgentSession
|
||||
{
|
||||
private static readonly JsonSerializerOptions DefaultJsonOptions = JsonSerialization.CreateWebOptions();
|
||||
|
||||
public AryxCopilotAgentSession()
|
||||
{
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public AryxCopilotAgentSession(string? sessionId, AgentSessionStateBag? stateBag = null)
|
||||
: base(stateBag ?? new AgentSessionStateBag())
|
||||
{
|
||||
SessionId = sessionId;
|
||||
}
|
||||
|
||||
[JsonPropertyName("sessionId")]
|
||||
public string? SessionId { get; set; }
|
||||
|
||||
internal JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
JsonSerializerOptions options = jsonSerializerOptions ?? DefaultJsonOptions;
|
||||
return JsonSerializer.SerializeToElement(this, options);
|
||||
}
|
||||
|
||||
internal static AryxCopilotAgentSession Deserialize(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
if (serializedState.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new ArgumentException("The serialized session state must be a JSON object.", nameof(serializedState));
|
||||
}
|
||||
|
||||
JsonSerializerOptions options = jsonSerializerOptions ?? DefaultJsonOptions;
|
||||
return serializedState.Deserialize<AryxCopilotAgentSession>(options)
|
||||
?? new AryxCopilotAgentSession();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
using System.Linq;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.GitHub.Copilot;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class CopilotAgentBundle : ProviderAgentBundle
|
||||
{
|
||||
private static readonly string[] RequiredPromptTools =
|
||||
[
|
||||
"ask_user",
|
||||
"report_intent",
|
||||
"task_complete"
|
||||
];
|
||||
private const string HandoffToolPrefix = "handoff_to_";
|
||||
private readonly List<IAsyncDisposable> _disposables = [];
|
||||
|
||||
internal CopilotAgentBundle(IReadOnlyList<AIAgent> agents, bool hasConfiguredHooks)
|
||||
{
|
||||
Agents = agents;
|
||||
HasConfiguredHooks = hasConfiguredHooks;
|
||||
}
|
||||
|
||||
public override IReadOnlyList<AIAgent> Agents { get; }
|
||||
|
||||
public override bool HasConfiguredHooks { get; }
|
||||
|
||||
public override IProviderTranscriptProjector TranscriptProjector { get; } = CopilotTranscriptProjector.Instance;
|
||||
|
||||
public static async Task<CopilotAgentBundle> CreateAsync(
|
||||
RunTurnCommandDto command,
|
||||
Func<WorkflowNodeDto, PermissionRequest, PermissionInvocation, Task<PermissionRequestResult>> onPermissionRequest,
|
||||
Func<WorkflowNodeDto, UserInputRequest, UserInputInvocation, Task<UserInputResponse>> onUserInputRequest,
|
||||
Action<WorkflowNodeDto, SessionEvent>? onSessionEvent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<IAsyncDisposable> disposables = [];
|
||||
List<AIAgent> agents = [];
|
||||
ResolvedHookSet configuredHooks = await HookConfigLoader.LoadAsync(command.ProjectPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
IHookCommandRunner hookCommandRunner = HookCommandRunner.Instance;
|
||||
SessionToolingBundle? toolingBundle = command.Tooling is null
|
||||
? null
|
||||
: await SessionToolingBundle.CreateAsync(command.Tooling, command.ProjectPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (toolingBundle is not null)
|
||||
{
|
||||
disposables.Add(toolingBundle);
|
||||
}
|
||||
|
||||
IReadOnlyList<WorkflowNodeDto> agentNodes = command.Workflow.GetAllAgentNodes(command.WorkflowLibrary);
|
||||
|
||||
if (agentNodes.Count > 0)
|
||||
{
|
||||
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions();
|
||||
|
||||
// Share a single CopilotClient across all agents to avoid spawning
|
||||
// multiple CLI processes that race on token refresh during auto-login.
|
||||
CopilotClient sharedClient = new(clientOptions);
|
||||
await sharedClient.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach ((WorkflowNodeDto definition, int agentIndex) in agentNodes.Select((definition, index) => (definition, index)))
|
||||
{
|
||||
SessionConfig sessionConfig = CreateSessionConfig(
|
||||
command,
|
||||
definition,
|
||||
agentIndex,
|
||||
(request, invocation) => onPermissionRequest(definition, request, invocation),
|
||||
(request, invocation) => onUserInputRequest(definition, request, invocation),
|
||||
evt => onSessionEvent?.Invoke(definition, evt),
|
||||
configuredHooks,
|
||||
hookCommandRunner);
|
||||
|
||||
ApplySessionTooling(sessionConfig, toolingBundle?.McpServers, toolingBundle?.Tools);
|
||||
ApplyPromptInvocation(sessionConfig, command.PromptInvocation);
|
||||
|
||||
AryxCopilotAgent agent = new(
|
||||
sharedClient,
|
||||
sessionConfig,
|
||||
ownsClient: false,
|
||||
id: definition.GetAgentId(),
|
||||
name: definition.GetAgentName(),
|
||||
description: NormalizeOptionalString(definition.Config.Description));
|
||||
|
||||
AIAgent instrumentedAgent = new AIAgentBuilder(agent).UseOpenTelemetry().Build();
|
||||
agents.Add(instrumentedAgent);
|
||||
disposables.Add(agent);
|
||||
if (instrumentedAgent is IDisposable instrumentedDisposable)
|
||||
{
|
||||
disposables.Add(new SyncDisposableAdapter(instrumentedDisposable));
|
||||
}
|
||||
}
|
||||
|
||||
// The bundle owns the shared client — disposed after all agents.
|
||||
disposables.Add(sharedClient);
|
||||
}
|
||||
|
||||
CopilotAgentBundle bundle = new(agents, hasConfiguredHooks: !configuredHooks.IsEmpty);
|
||||
bundle._disposables.AddRange(disposables);
|
||||
return bundle;
|
||||
}
|
||||
|
||||
internal static SessionConfig CreateSessionConfig(
|
||||
RunTurnCommandDto command,
|
||||
WorkflowNodeDto definition,
|
||||
int agentIndex,
|
||||
PermissionRequestHandler? onPermissionRequest = null,
|
||||
UserInputHandler? onUserInputRequest = null,
|
||||
SessionEventHandler? onSessionEvent = null,
|
||||
ResolvedHookSet? configuredHooks = null,
|
||||
IHookCommandRunner? hookCommandRunner = null)
|
||||
{
|
||||
return new SessionConfig
|
||||
{
|
||||
Model = definition.Config.Model,
|
||||
ReasoningEffort = definition.Config.ReasoningEffort,
|
||||
SystemMessage = new SystemMessageConfig
|
||||
{
|
||||
Content = AgentInstructionComposer.Compose(
|
||||
command.Workflow,
|
||||
definition,
|
||||
agentIndex,
|
||||
command.WorkspaceKind,
|
||||
command.Mode,
|
||||
command.ProjectInstructions,
|
||||
command.PromptInvocation),
|
||||
},
|
||||
WorkingDirectory = command.ProjectPath,
|
||||
OnPermissionRequest = onPermissionRequest,
|
||||
OnUserInputRequest = onUserInputRequest,
|
||||
Hooks = CopilotSessionHooks.Create(command, definition, configuredHooks, hookCommandRunner),
|
||||
OnEvent = onSessionEvent,
|
||||
Streaming = true,
|
||||
CustomAgents = CreateCustomAgents(definition.Config.Copilot?.CustomAgents),
|
||||
Agent = ResolveEffectiveAgent(definition.Config.Copilot?.Agent, command.PromptInvocation),
|
||||
SkillDirectories = CreateStringList(definition.Config.Copilot?.SkillDirectories),
|
||||
DisabledSkills = CreateStringList(definition.Config.Copilot?.DisabledSkills),
|
||||
InfiniteSessions = CreateInfiniteSessions(definition.Config.Copilot?.InfiniteSessions),
|
||||
};
|
||||
}
|
||||
|
||||
internal static void ApplySessionTooling(
|
||||
SessionConfig sessionConfig,
|
||||
Dictionary<string, object>? mcpServers,
|
||||
IReadOnlyList<AIFunction>? tools)
|
||||
{
|
||||
if (mcpServers is { Count: > 0 })
|
||||
{
|
||||
sessionConfig.McpServers = mcpServers;
|
||||
}
|
||||
|
||||
if (tools is { Count: > 0 })
|
||||
{
|
||||
sessionConfig.Tools = tools.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
internal static void ApplyPromptInvocation(
|
||||
SessionConfig sessionConfig,
|
||||
RunTurnPromptInvocationDto? promptInvocation)
|
||||
{
|
||||
IReadOnlyList<string>? allowedTools = NormalizeToolNames(promptInvocation?.Tools);
|
||||
if (allowedTools is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
sessionConfig.AvailableTools = BuildAvailableTools(sessionConfig.AvailableTools, allowedTools);
|
||||
|
||||
if (sessionConfig.Tools is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
List<AIFunction> filteredTools = sessionConfig.Tools
|
||||
.Where(tool => IsAlwaysAllowedTool(tool.Name) || allowedTools.Contains(tool.Name, StringComparer.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
sessionConfig.Tools = filteredTools.Count > 0 ? filteredTools : null;
|
||||
}
|
||||
|
||||
internal static List<CustomAgentConfig>? CreateCustomAgents(
|
||||
IReadOnlyList<RunTurnCustomAgentConfigDto>? customAgents)
|
||||
{
|
||||
if (customAgents is not { Count: > 0 })
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return customAgents.Select(customAgent => new CustomAgentConfig
|
||||
{
|
||||
Name = customAgent.Name,
|
||||
DisplayName = NormalizeOptionalString(customAgent.DisplayName),
|
||||
Description = NormalizeOptionalString(customAgent.Description),
|
||||
Tools = customAgent.Tools is null ? null : [.. customAgent.Tools],
|
||||
Prompt = customAgent.Prompt,
|
||||
McpServers = customAgent.McpServers.Count == 0
|
||||
? null
|
||||
: SessionToolingBundle.BuildMcpServerConfigurations(customAgent.McpServers),
|
||||
Infer = customAgent.Infer,
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
internal static InfiniteSessionConfig? CreateInfiniteSessions(RunTurnInfiniteSessionsConfigDto? config)
|
||||
{
|
||||
if (config is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new InfiniteSessionConfig
|
||||
{
|
||||
Enabled = config.Enabled,
|
||||
BackgroundCompactionThreshold = config.BackgroundCompactionThreshold,
|
||||
BufferExhaustionThreshold = config.BufferExhaustionThreshold,
|
||||
};
|
||||
}
|
||||
|
||||
internal static AIAgentHostOptions CreateAgentHostOptions()
|
||||
{
|
||||
return AgentHostOptionsFactory.CreateDefault();
|
||||
}
|
||||
|
||||
internal static HandoffWorkflowBuilder CreateHandoffWorkflowBuilder(
|
||||
AIAgent entryAgent,
|
||||
HandoffModeSettingsDto? settings = null)
|
||||
{
|
||||
return WorkflowOrchestrationFactory.CreateHandoffWorkflowBuilder(entryAgent, settings);
|
||||
}
|
||||
|
||||
internal static Workflow CreateHandoffWorkflow(
|
||||
WorkflowDefinitionDto workflowDefinition,
|
||||
IReadOnlyList<AIAgent> agents)
|
||||
{
|
||||
return WorkflowOrchestrationFactory.CreateHandoffWorkflow(workflowDefinition, agents);
|
||||
}
|
||||
|
||||
internal static GroupChatWorkflowBuilder CreateGroupChatWorkflowBuilder(
|
||||
WorkflowDefinitionDto workflowDefinition,
|
||||
IReadOnlyList<AIAgent> agents)
|
||||
{
|
||||
return WorkflowOrchestrationFactory.CreateGroupChatWorkflowBuilder(workflowDefinition, agents);
|
||||
}
|
||||
|
||||
internal static Workflow CreateGroupChatWorkflow(
|
||||
WorkflowDefinitionDto workflowDefinition,
|
||||
IReadOnlyList<AIAgent> agents)
|
||||
{
|
||||
return WorkflowOrchestrationFactory.CreateGroupChatWorkflow(workflowDefinition, agents);
|
||||
}
|
||||
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (IAsyncDisposable disposable in _disposables)
|
||||
{
|
||||
await disposable.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string>? CreateStringList(IReadOnlyList<string>? values)
|
||||
{
|
||||
return values is { Count: > 0 }
|
||||
? [.. values]
|
||||
: null;
|
||||
}
|
||||
|
||||
private static List<string> BuildAvailableTools(
|
||||
ICollection<string>? existingAvailableTools,
|
||||
IReadOnlyList<string> allowedTools)
|
||||
{
|
||||
List<string> availableTools = existingAvailableTools is { Count: > 0 }
|
||||
? existingAvailableTools
|
||||
.Where(tool => allowedTools.Contains(tool, StringComparer.OrdinalIgnoreCase))
|
||||
.ToList()
|
||||
: [.. allowedTools];
|
||||
|
||||
foreach (string requiredTool in RequiredPromptTools)
|
||||
{
|
||||
if (!availableTools.Contains(requiredTool, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
availableTools.Add(requiredTool);
|
||||
}
|
||||
}
|
||||
|
||||
return availableTools;
|
||||
}
|
||||
|
||||
private static bool IsAlwaysAllowedTool(string toolName)
|
||||
{
|
||||
return toolName.StartsWith(HandoffToolPrefix, StringComparison.Ordinal)
|
||||
|| RequiredPromptTools.Contains(toolName, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? NormalizeToolNames(IReadOnlyList<string>? values)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return values
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string? ResolveEffectiveAgent(
|
||||
string? defaultAgent,
|
||||
RunTurnPromptInvocationDto? promptInvocation)
|
||||
{
|
||||
string? promptAgent = NormalizeOptionalString(promptInvocation?.Agent);
|
||||
if (!string.IsNullOrWhiteSpace(promptAgent)
|
||||
&& !string.Equals(promptAgent, "plan", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return promptAgent;
|
||||
}
|
||||
|
||||
IReadOnlyList<string>? promptTools = NormalizeToolNames(promptInvocation?.Tools);
|
||||
if (promptTools is { Count: > 0 })
|
||||
{
|
||||
return "agent";
|
||||
}
|
||||
|
||||
return NormalizeOptionalString(defaultAgent);
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adapts a synchronous <see cref="IDisposable"/> to <see cref="IAsyncDisposable"/>
|
||||
/// so it can be tracked in the async disposal pipeline.
|
||||
/// </summary>
|
||||
private sealed class SyncDisposableAdapter(IDisposable inner) : IAsyncDisposable
|
||||
{
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
inner.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
using GitHub.Copilot.SDK;
|
||||
using GitHub.Copilot.SDK.Rpc;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class CopilotAgentProvider : IAgentProvider
|
||||
{
|
||||
private const string AskUserToolName = "ask_user";
|
||||
private static readonly HashSet<string> ExcludedRuntimeToolNames = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
AskUserToolName,
|
||||
"report_intent",
|
||||
"task_complete",
|
||||
};
|
||||
|
||||
private static readonly string[] AuthenticationErrorIndicators =
|
||||
[
|
||||
"login",
|
||||
"log in",
|
||||
"sign in",
|
||||
"authenticate",
|
||||
"authentication",
|
||||
"not signed in",
|
||||
"not logged in",
|
||||
"reauth",
|
||||
"credential",
|
||||
];
|
||||
|
||||
public ITurnWorkflowRunner CreateWorkflowRunner(WorkflowValidator workflowValidator)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflowValidator);
|
||||
return new AgentWorkflowTurnRunner(new CopilotTurnRunnerSupport(), workflowValidator);
|
||||
}
|
||||
|
||||
public Task<SidecarCapabilitiesDto> GetCapabilitiesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return BuildCapabilitiesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public IProviderSessionManager CreateSessionManager()
|
||||
{
|
||||
return new CopilotSessionManager();
|
||||
}
|
||||
|
||||
private static async Task<SidecarCapabilitiesDto> BuildCapabilitiesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
CopilotCliContext cliContext = CopilotCliPathResolver.ResolveCliContext();
|
||||
CapabilityProbeResult probe = await ProbeCapabilitiesAsync(cliContext, cancellationToken).ConfigureAwait(false);
|
||||
return CreateCapabilities(probe.Models, probe.RuntimeTools, probe.Connection);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
SidecarConnectionDiagnosticsDto connection = CreateMissingCliDiagnostics(exception);
|
||||
Console.Error.WriteLine($"[aryx sidecar] {connection.Summary} {exception.Message}");
|
||||
return CreateCapabilities([], [], connection);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<CapabilityProbeResult> ProbeCapabilitiesAsync(
|
||||
CopilotCliContext cliContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IReadOnlyList<SidecarModelCapabilityDto> models = [];
|
||||
IReadOnlyList<SidecarRuntimeToolDto> runtimeTools = [];
|
||||
SidecarCopilotAccountDiagnosticsDto? account = null;
|
||||
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null;
|
||||
Task<SidecarCopilotCliVersionDiagnosticsDto> cliVersionTask =
|
||||
CopilotConnectionMetadataResolver.GetCliVersionDiagnosticsAsync(cliContext, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions(cliContext);
|
||||
|
||||
await using CopilotClient client = new(clientOptions);
|
||||
await client.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
GetAuthStatusResponse? authStatus =
|
||||
await CopilotConnectionMetadataResolver.TryGetAuthStatusAsync(client, cancellationToken).ConfigureAwait(false);
|
||||
account = await CopilotConnectionMetadataResolver.CreateAccountDiagnosticsAsync(
|
||||
authStatus,
|
||||
cliContext.Environment,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
models = await ListAvailableModelsAsync(client, cancellationToken).ConfigureAwait(false);
|
||||
runtimeTools = await TryListAvailableRuntimeToolsAsync(client, cancellationToken).ConfigureAwait(false);
|
||||
cliVersion = await cliVersionTask.ConfigureAwait(false);
|
||||
|
||||
return new CapabilityProbeResult(
|
||||
models,
|
||||
runtimeTools,
|
||||
CreateReadyConnectionDiagnostics(cliContext.CliPath, models.Count, cliVersion, account));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
cliVersion = await cliVersionTask.ConfigureAwait(false);
|
||||
Console.Error.WriteLine($"[aryx sidecar] Failed to list available Copilot models: {exception.Message}");
|
||||
|
||||
return new CapabilityProbeResult(
|
||||
models,
|
||||
runtimeTools,
|
||||
CreateFailureConnectionDiagnostics(cliContext.CliPath, exception, cliVersion, account));
|
||||
}
|
||||
}
|
||||
|
||||
private static SidecarCapabilitiesDto CreateCapabilities(
|
||||
IReadOnlyList<SidecarModelCapabilityDto> models,
|
||||
IReadOnlyList<SidecarRuntimeToolDto> runtimeTools,
|
||||
SidecarConnectionDiagnosticsDto connection)
|
||||
{
|
||||
return new SidecarCapabilitiesDto
|
||||
{
|
||||
Modes = BuildModeCapabilities(),
|
||||
Models = models,
|
||||
RuntimeTools = runtimeTools,
|
||||
Connection = connection,
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, SidecarModeCapabilityDto> BuildModeCapabilities()
|
||||
{
|
||||
return new Dictionary<string, SidecarModeCapabilityDto>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["single"] = new() { Available = true },
|
||||
["sequential"] = new() { Available = true },
|
||||
["concurrent"] = new() { Available = true },
|
||||
["handoff"] = new() { Available = true },
|
||||
["group-chat"] = new() { Available = true },
|
||||
["magentic"] = new()
|
||||
{
|
||||
Available = false,
|
||||
Reason = "Microsoft Agent Framework currently documents Magentic orchestration as unsupported in C#.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<SidecarModelCapabilityDto>> ListAvailableModelsAsync(
|
||||
CopilotClient client,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<ModelInfo> models = await client.ListModelsAsync(cancellationToken).ConfigureAwait(false);
|
||||
return models
|
||||
.Select(model => new SidecarModelCapabilityDto
|
||||
{
|
||||
Id = model.Id,
|
||||
Name = model.Name,
|
||||
SupportedReasoningEfforts = (model.SupportedReasoningEfforts ?? [])
|
||||
.Where(IsReasoningEffort)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList(),
|
||||
DefaultReasoningEffort = IsReasoningEffort(model.DefaultReasoningEffort)
|
||||
? model.DefaultReasoningEffort
|
||||
: null,
|
||||
})
|
||||
.OrderBy(model => model.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<SidecarRuntimeToolDto>> TryListAvailableRuntimeToolsAsync(
|
||||
CopilotClient client,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await ListAvailableRuntimeToolsAsync(client, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx sidecar] Failed to list available Copilot runtime tools: {exception.Message}");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<SidecarRuntimeToolDto>> ListAvailableRuntimeToolsAsync(
|
||||
CopilotClient client,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ToolsListResult result = await client.Rpc.Tools.ListAsync(null!, cancellationToken).ConfigureAwait(false);
|
||||
return MapRuntimeTools(result.Tools);
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<SidecarRuntimeToolDto> MapRuntimeTools(IEnumerable<Tool> tools)
|
||||
{
|
||||
return tools
|
||||
.Where(ShouldIncludeRuntimeTool)
|
||||
.Where(tool => !string.IsNullOrWhiteSpace(tool.Name))
|
||||
.Select(tool => new SidecarRuntimeToolDto
|
||||
{
|
||||
Id = tool.Name.Trim(),
|
||||
Label = tool.Name.Trim(),
|
||||
Description = string.IsNullOrWhiteSpace(tool.Description) ? null : tool.Description.Trim(),
|
||||
})
|
||||
.DistinctBy(tool => tool.Id, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(tool => tool.Label, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static bool ShouldIncludeRuntimeTool(Tool tool)
|
||||
{
|
||||
string? toolName = string.IsNullOrWhiteSpace(tool.Name) ? null : tool.Name.Trim();
|
||||
return toolName is not null
|
||||
&& !ExcludedRuntimeToolNames.Contains(toolName);
|
||||
}
|
||||
|
||||
private static bool IsReasoningEffort(string? value)
|
||||
{
|
||||
return value is "low" or "medium" or "high" or "xhigh";
|
||||
}
|
||||
|
||||
internal static SidecarConnectionDiagnosticsDto CreateMissingCliDiagnostics(Exception exception)
|
||||
{
|
||||
return new SidecarConnectionDiagnosticsDto
|
||||
{
|
||||
Status = "copilot-cli-missing",
|
||||
Summary = "GitHub Copilot CLI is not installed or is not available on PATH.",
|
||||
Detail = exception.Message,
|
||||
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
};
|
||||
}
|
||||
|
||||
internal static SidecarConnectionDiagnosticsDto CreateReadyConnectionDiagnostics(
|
||||
string cliPath,
|
||||
int modelCount,
|
||||
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null,
|
||||
SidecarCopilotAccountDiagnosticsDto? account = null)
|
||||
{
|
||||
string summary = modelCount switch
|
||||
{
|
||||
0 => "Connected to GitHub Copilot, but no models were reported.",
|
||||
1 => "Connected to GitHub Copilot. 1 model is available.",
|
||||
_ => $"Connected to GitHub Copilot. {modelCount} models are available.",
|
||||
};
|
||||
|
||||
return new SidecarConnectionDiagnosticsDto
|
||||
{
|
||||
Status = "ready",
|
||||
Summary = summary,
|
||||
Detail = $"Using Copilot CLI at {cliPath}.",
|
||||
CopilotCliPath = cliPath,
|
||||
CopilotCliVersion = cliVersion,
|
||||
Account = account,
|
||||
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
};
|
||||
}
|
||||
|
||||
internal static SidecarConnectionDiagnosticsDto CreateFailureConnectionDiagnostics(
|
||||
string? cliPath,
|
||||
Exception exception,
|
||||
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null,
|
||||
SidecarCopilotAccountDiagnosticsDto? account = null)
|
||||
{
|
||||
string status = ClassifyConnectionStatus(exception);
|
||||
string summary = status == "copilot-auth-required"
|
||||
? "GitHub Copilot requires authentication before Aryx can load models."
|
||||
: "GitHub Copilot was found, but Aryx could not load its model list.";
|
||||
|
||||
return new SidecarConnectionDiagnosticsDto
|
||||
{
|
||||
Status = status,
|
||||
Summary = summary,
|
||||
Detail = exception.Message,
|
||||
CopilotCliPath = cliPath,
|
||||
CopilotCliVersion = cliVersion,
|
||||
Account = account,
|
||||
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
};
|
||||
}
|
||||
|
||||
internal static string ClassifyConnectionStatus(Exception exception)
|
||||
{
|
||||
string message = exception.Message;
|
||||
if (AuthenticationErrorIndicators.Any(indicator =>
|
||||
message.Contains(indicator, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return "copilot-auth-required";
|
||||
}
|
||||
|
||||
return "copilot-error";
|
||||
}
|
||||
|
||||
private sealed record CapabilityProbeResult(
|
||||
IReadOnlyList<SidecarModelCapabilityDto> Models,
|
||||
IReadOnlyList<SidecarRuntimeToolDto> RuntimeTools,
|
||||
SidecarConnectionDiagnosticsDto Connection);
|
||||
}
|
||||
@@ -0,0 +1,729 @@
|
||||
using System.Collections.Concurrent;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class CopilotApprovalCoordinator
|
||||
{
|
||||
private const string ApprovedDecision = "approved";
|
||||
private const string RejectedDecision = "rejected";
|
||||
private const string ToolCallApprovalKind = "tool-call";
|
||||
private const string StoreMemoryToolName = "store_memory";
|
||||
private const string WebFetchToolName = "web_fetch";
|
||||
private const string ShellPermissionKind = "shell";
|
||||
private const string WritePermissionKind = "write";
|
||||
private const string ReadPermissionKind = "read";
|
||||
private const string McpPermissionKind = "mcp";
|
||||
private const string UrlPermissionKind = "url";
|
||||
private const string MemoryPermissionKind = "memory";
|
||||
private const string CustomToolPermissionKind = "custom-tool";
|
||||
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);
|
||||
|
||||
public Task ResolveApprovalAsync(
|
||||
ResolveApprovalCommandDto command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
|
||||
string approvalId = RequireApprovalId(command.ApprovalId);
|
||||
PendingApprovalRequest pending = GetPendingApproval(approvalId);
|
||||
PermissionRequestResultKind decision = ParseDecision(command.Decision);
|
||||
|
||||
if (!pending.Decision.TrySetResult(decision))
|
||||
{
|
||||
throw new InvalidOperationException($"Approval \"{approvalId}\" is no longer pending.");
|
||||
}
|
||||
|
||||
if (decision == PermissionRequestResultKind.Approved && command.AlwaysApprove)
|
||||
{
|
||||
CacheApprovedToolForRequest(pending.RequestId, pending.ApprovalCacheKey);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task<PermissionRequestResult> RequestApprovalAsync(
|
||||
RunTurnCommandDto command,
|
||||
WorkflowNodeDto agent,
|
||||
PermissionRequest request,
|
||||
PermissionInvocation invocation,
|
||||
ToolCallRegistry toolCalls,
|
||||
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await RequestApprovalAsync(
|
||||
command,
|
||||
agent,
|
||||
request,
|
||||
invocation,
|
||||
toolCalls,
|
||||
onActivity: null,
|
||||
onApproval,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<PermissionRequestResult> RequestApprovalAsync(
|
||||
RunTurnCommandDto command,
|
||||
WorkflowNodeDto agent,
|
||||
PermissionRequest request,
|
||||
PermissionInvocation invocation,
|
||||
ToolCallRegistry toolCalls,
|
||||
Func<AgentActivityEventDto, Task>? onActivity,
|
||||
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string? toolName = ResolveApprovalToolName(request, toolCalls);
|
||||
string? autoApprovedToolName = ResolveAutoApprovedToolName(request);
|
||||
string? mcpServerApprovalKey = ResolveMcpServerApprovalKey(request, command.Tooling?.McpServers);
|
||||
string? approvalCacheKey = ResolveApprovalCacheKey(toolName, autoApprovedToolName);
|
||||
|
||||
AgentActivityEventDto? fileChangeActivity = BuildToolCallFileChangeActivity(command, agent, request, toolName);
|
||||
if (fileChangeActivity is not null && onActivity is not null)
|
||||
{
|
||||
await onActivity(fileChangeActivity).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (IsToolApprovedForRequest(command.RequestId, approvalCacheKey)
|
||||
|| !RequiresToolCallApproval(command.Workflow.Settings.ApprovalPolicy, agent.GetAgentId(), toolName, autoApprovedToolName, mcpServerApprovalKey))
|
||||
{
|
||||
return CreateApprovalResult(PermissionRequestResultKind.Approved);
|
||||
}
|
||||
|
||||
PendingApprovalRequest pending = CreatePendingApproval(command, approvalCacheKey);
|
||||
if (!_pendingApprovals.TryAdd(pending.ApprovalId, pending))
|
||||
{
|
||||
throw new InvalidOperationException($"Approval \"{pending.ApprovalId}\" is already pending.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await onApproval(BuildPermissionApprovalEvent(
|
||||
command,
|
||||
agent,
|
||||
request,
|
||||
invocation,
|
||||
pending.ApprovalId,
|
||||
toolName))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
using CancellationTokenRegistration registration = cancellationToken.Register(
|
||||
static state =>
|
||||
{
|
||||
((TaskCompletionSource<PermissionRequestResultKind>)state!)
|
||||
.TrySetCanceled();
|
||||
},
|
||||
pending.Decision);
|
||||
|
||||
PermissionRequestResultKind decision = await pending.Decision.Task.ConfigureAwait(false);
|
||||
return CreateApprovalResult(decision);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pendingApprovals.TryRemove(pending.ApprovalId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
internal static ApprovalRequestedEventDto BuildPermissionApprovalEvent(
|
||||
RunTurnCommandDto command,
|
||||
WorkflowNodeDto agent,
|
||||
PermissionRequest request,
|
||||
PermissionInvocation invocation,
|
||||
string approvalId,
|
||||
string? toolName)
|
||||
{
|
||||
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
|
||||
? NormalizeOptionalString(urlRequest.Url)
|
||||
: null;
|
||||
string title = normalizedToolName is null
|
||||
? $"Approve {permissionKind}"
|
||||
: $"Approve {normalizedToolName}";
|
||||
string detail = normalizedToolName is null
|
||||
? $"{agentName} requested {permissionKind} permission"
|
||||
: $"{agentName} requested {permissionKind} permission for tool \"{normalizedToolName}\"";
|
||||
|
||||
if (requestedUrl is not null)
|
||||
{
|
||||
detail = $"{detail} to access \"{requestedUrl}\"";
|
||||
}
|
||||
|
||||
if (sessionId is not null)
|
||||
{
|
||||
detail = normalizedToolName is null
|
||||
? $"{detail} for Copilot session {sessionId}"
|
||||
: $"{detail} in Copilot session {sessionId}";
|
||||
}
|
||||
|
||||
detail = $"{detail}.";
|
||||
|
||||
return new ApprovalRequestedEventDto
|
||||
{
|
||||
Type = "approval-requested",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
ApprovalId = approvalId,
|
||||
ApprovalKind = ToolCallApprovalKind,
|
||||
AgentId = NormalizeOptionalString(agentId),
|
||||
AgentName = NormalizeOptionalString(agentName),
|
||||
ToolName = normalizedToolName,
|
||||
PermissionKind = permissionKind,
|
||||
Title = title,
|
||||
Detail = detail,
|
||||
PermissionDetail = BuildPermissionDetail(request, command.Tooling?.McpServers),
|
||||
};
|
||||
}
|
||||
|
||||
internal static AgentActivityEventDto? BuildToolCallFileChangeActivity(
|
||||
RunTurnCommandDto command,
|
||||
WorkflowNodeDto agent,
|
||||
PermissionRequest request,
|
||||
string? toolName)
|
||||
{
|
||||
if (request is not PermissionRequestWrite write)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? filePath = NormalizeOptionalString(write.FileName);
|
||||
if (filePath is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string agentId = agent.GetAgentId();
|
||||
string agentName = agent.GetAgentName();
|
||||
return new AgentActivityEventDto
|
||||
{
|
||||
Type = "agent-activity",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
ActivityType = ToolCallingActivityType,
|
||||
AgentId = NormalizeOptionalString(agentId),
|
||||
AgentName = NormalizeOptionalString(agentName),
|
||||
ToolName = NormalizeOptionalString(toolName),
|
||||
ToolCallId = NormalizeOptionalString(write.ToolCallId),
|
||||
FileChanges =
|
||||
[
|
||||
new ToolCallFileChangeDto
|
||||
{
|
||||
Path = filePath,
|
||||
Diff = NormalizeOptionalPreviewText(write.Diff),
|
||||
NewFileContents = NormalizeOptionalPreviewText(write.NewFileContents),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
internal static PermissionDetailDto BuildPermissionDetail(
|
||||
PermissionRequest request,
|
||||
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
return request switch
|
||||
{
|
||||
PermissionRequestShell shell => new PermissionDetailDto
|
||||
{
|
||||
Kind = ShellPermissionKind,
|
||||
Intention = NormalizeOptionalString(shell.Intention),
|
||||
Command = NormalizeOptionalString(shell.FullCommandText),
|
||||
Warning = NormalizeOptionalString(shell.Warning),
|
||||
PossiblePaths = NormalizeOptionalStringList(shell.PossiblePaths),
|
||||
PossibleUrls = NormalizeOptionalStringList(shell.PossibleUrls.Select(static candidate => candidate.Url)),
|
||||
HasWriteFileRedirection = shell.HasWriteFileRedirection,
|
||||
},
|
||||
PermissionRequestWrite write => new PermissionDetailDto
|
||||
{
|
||||
Kind = WritePermissionKind,
|
||||
Intention = NormalizeOptionalString(write.Intention),
|
||||
FileName = NormalizeOptionalString(write.FileName),
|
||||
Diff = NormalizeOptionalString(write.Diff),
|
||||
NewFileContents = NormalizeOptionalString(write.NewFileContents),
|
||||
},
|
||||
PermissionRequestRead read => new PermissionDetailDto
|
||||
{
|
||||
Kind = ReadPermissionKind,
|
||||
Intention = NormalizeOptionalString(read.Intention),
|
||||
Path = NormalizeOptionalString(read.Path),
|
||||
},
|
||||
PermissionRequestMcp mcp => new PermissionDetailDto
|
||||
{
|
||||
Kind = McpPermissionKind,
|
||||
ServerName = NormalizeOptionalString(mcp.ServerName),
|
||||
ToolTitle = NormalizeOptionalString(mcp.ToolTitle),
|
||||
Args = mcp.Args,
|
||||
ReadOnly = mcp.ReadOnly,
|
||||
},
|
||||
PermissionRequestUrl url => new PermissionDetailDto
|
||||
{
|
||||
Kind = UrlPermissionKind,
|
||||
Intention = NormalizeOptionalString(url.Intention),
|
||||
Url = NormalizeOptionalString(url.Url),
|
||||
},
|
||||
PermissionRequestMemory memory => new PermissionDetailDto
|
||||
{
|
||||
Kind = MemoryPermissionKind,
|
||||
Subject = NormalizeOptionalString(memory.Subject),
|
||||
Fact = NormalizeOptionalString(memory.Fact),
|
||||
Citations = NormalizeOptionalString(memory.Citations),
|
||||
},
|
||||
PermissionRequestCustomTool customTool => new PermissionDetailDto
|
||||
{
|
||||
Kind = CustomToolPermissionKind,
|
||||
ToolDescription = NormalizeOptionalString(customTool.ToolDescription),
|
||||
Args = customTool.Args,
|
||||
},
|
||||
PermissionRequestHook hook => BuildHookPermissionDetail(hook, configuredMcpServers),
|
||||
_ => new PermissionDetailDto
|
||||
{
|
||||
Kind = NormalizeOptionalString(request.Kind) ?? "unknown",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
internal static bool RequiresToolCallApproval(
|
||||
ApprovalPolicyDto? approvalPolicy,
|
||||
string agentId,
|
||||
string? toolName,
|
||||
string? autoApprovedToolName = null,
|
||||
string? mcpServerApprovalKey = null)
|
||||
{
|
||||
if (approvalPolicy?.Rules is null || approvalPolicy.Rules.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!HasMatchingToolCallCheckpoint(approvalPolicy.Rules, agentId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
IReadOnlyList<string> autoApprovedToolNames = approvalPolicy.AutoApprovedToolNames;
|
||||
if (autoApprovedToolNames.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return !MatchesAutoApprovedTool(autoApprovedToolNames, toolName, autoApprovedToolName)
|
||||
&& !MatchesAutoApprovedToolName(autoApprovedToolNames, mcpServerApprovalKey);
|
||||
}
|
||||
|
||||
internal static bool TryGetApprovalToolName(
|
||||
PermissionRequest request,
|
||||
ToolCallRegistry? toolCalls,
|
||||
out string? toolName)
|
||||
{
|
||||
toolName = ResolveApprovalToolName(request, toolCalls);
|
||||
return toolName is not null;
|
||||
}
|
||||
|
||||
internal static bool TryGetApprovalToolName(PermissionRequest request, out string? toolName)
|
||||
=> TryGetApprovalToolName(request, toolCalls: null, out toolName);
|
||||
|
||||
internal void ClearRequestApprovals(string requestId)
|
||||
{
|
||||
string? normalizedRequestId = NormalizeOptionalString(requestId);
|
||||
if (normalizedRequestId is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_requestApprovedTools.TryRemove(normalizedRequestId, out _);
|
||||
}
|
||||
|
||||
private static bool HasMatchingToolCallCheckpoint(
|
||||
IReadOnlyList<ApprovalCheckpointRuleDto> rules,
|
||||
string agentId)
|
||||
{
|
||||
foreach (ApprovalCheckpointRuleDto rule in rules)
|
||||
{
|
||||
if (!string.Equals(rule.Kind, ToolCallApprovalKind, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rule.AgentIds.Count == 0
|
||||
|| rule.AgentIds.Any(candidate =>
|
||||
string.Equals(candidate, agentId, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static PendingApprovalRequest CreatePendingApproval(
|
||||
RunTurnCommandDto command,
|
||||
string? approvalCacheKey)
|
||||
{
|
||||
return new PendingApprovalRequest(
|
||||
command.RequestId,
|
||||
command.SessionId,
|
||||
CreateApprovalRequestId(),
|
||||
NormalizeOptionalString(approvalCacheKey),
|
||||
new TaskCompletionSource<PermissionRequestResultKind>(TaskCreationOptions.RunContinuationsAsynchronously));
|
||||
}
|
||||
|
||||
private static PermissionRequestResult CreateApprovalResult(PermissionRequestResultKind decision)
|
||||
{
|
||||
return new PermissionRequestResult
|
||||
{
|
||||
Kind = decision,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ResolveApprovalToolName(
|
||||
PermissionRequest request,
|
||||
ToolCallRegistry? toolCalls)
|
||||
{
|
||||
return GetDirectToolName(request)
|
||||
?? ResolveToolNameFromLookup(request, toolCalls)
|
||||
?? GetFallbackToolName(request);
|
||||
}
|
||||
|
||||
private static string? ResolveAutoApprovedToolName(PermissionRequest request)
|
||||
{
|
||||
return GetFallbackToolName(request);
|
||||
}
|
||||
|
||||
private const string McpServerApprovalPrefix = "mcp_server:";
|
||||
|
||||
private static string? ResolveMcpServerApprovalKey(
|
||||
PermissionRequest request,
|
||||
IReadOnlyList<RunTurnMcpServerConfigDto>? configuredMcpServers)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
return configuredMcpServers
|
||||
.Select(ResolveConfiguredMcpServerName)
|
||||
.OfType<string>()
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderByDescending(static serverName => serverName.Length)
|
||||
.FirstOrDefault(serverName => MatchesHookMcpServerToolName(normalizedToolName, serverName));
|
||||
}
|
||||
|
||||
private static string? ResolveApprovalCacheKey(
|
||||
string? toolName,
|
||||
string? autoApprovedToolName)
|
||||
{
|
||||
return NormalizeOptionalString(autoApprovedToolName)
|
||||
?? NormalizeOptionalString(toolName);
|
||||
}
|
||||
|
||||
private static string? GetDirectToolName(PermissionRequest request)
|
||||
{
|
||||
return request switch
|
||||
{
|
||||
PermissionRequestMcp mcp => NormalizeOptionalString(mcp.ToolName),
|
||||
PermissionRequestCustomTool customTool => NormalizeOptionalString(customTool.ToolName),
|
||||
PermissionRequestHook hook => NormalizeOptionalString(hook.ToolName),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ResolveToolNameFromLookup(
|
||||
PermissionRequest request,
|
||||
ToolCallRegistry? toolCalls)
|
||||
{
|
||||
if (toolCalls is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? toolCallId = GetToolCallId(request);
|
||||
if (toolCallId is null
|
||||
|| !toolCalls.TryGetToolName(toolCallId, out string? resolvedToolName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return NormalizeOptionalString(resolvedToolName);
|
||||
}
|
||||
|
||||
private static string? GetToolCallId(PermissionRequest request)
|
||||
{
|
||||
return request switch
|
||||
{
|
||||
PermissionRequestShell shell => NormalizeOptionalString(shell.ToolCallId),
|
||||
PermissionRequestWrite write => NormalizeOptionalString(write.ToolCallId),
|
||||
PermissionRequestRead read => NormalizeOptionalString(read.ToolCallId),
|
||||
PermissionRequestMcp mcp => NormalizeOptionalString(mcp.ToolCallId),
|
||||
PermissionRequestUrl url => NormalizeOptionalString(url.ToolCallId),
|
||||
PermissionRequestMemory memory => NormalizeOptionalString(memory.ToolCallId),
|
||||
PermissionRequestCustomTool customTool => NormalizeOptionalString(customTool.ToolCallId),
|
||||
PermissionRequestHook hook => NormalizeOptionalString(hook.ToolCallId),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? GetFallbackToolName(PermissionRequest request)
|
||||
{
|
||||
return request switch
|
||||
{
|
||||
PermissionRequestUrl => WebFetchToolName,
|
||||
PermissionRequestShell => ShellPermissionKind,
|
||||
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,
|
||||
string? autoApprovedToolName)
|
||||
{
|
||||
return MatchesAutoApprovedToolName(autoApprovedToolNames, toolName)
|
||||
|| MatchesAutoApprovedToolName(autoApprovedToolNames, autoApprovedToolName);
|
||||
}
|
||||
|
||||
private static bool MatchesAutoApprovedToolName(
|
||||
IReadOnlyList<string> autoApprovedToolNames,
|
||||
string? toolName)
|
||||
{
|
||||
string? normalizedToolName = NormalizeOptionalString(toolName);
|
||||
return normalizedToolName is not null
|
||||
&& autoApprovedToolNames.Any(candidate =>
|
||||
string.Equals(candidate, normalizedToolName, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private bool IsToolApprovedForRequest(string requestId, string? approvalCacheKey)
|
||||
{
|
||||
string? normalizedRequestId = NormalizeOptionalString(requestId);
|
||||
string? normalizedApprovalCacheKey = NormalizeOptionalString(approvalCacheKey);
|
||||
if (normalizedRequestId is null || normalizedApprovalCacheKey is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return _requestApprovedTools.TryGetValue(normalizedRequestId, out ConcurrentDictionary<string, byte>? approvedTools)
|
||||
&& approvedTools.ContainsKey(normalizedApprovalCacheKey);
|
||||
}
|
||||
|
||||
private void CacheApprovedToolForRequest(string requestId, string? approvalCacheKey)
|
||||
{
|
||||
string? normalizedRequestId = NormalizeOptionalString(requestId);
|
||||
string? normalizedApprovalCacheKey = NormalizeOptionalString(approvalCacheKey);
|
||||
if (normalizedRequestId is null || normalizedApprovalCacheKey is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ConcurrentDictionary<string, byte> approvedTools = _requestApprovedTools.GetOrAdd(
|
||||
normalizedRequestId,
|
||||
static _ => new ConcurrentDictionary<string, byte>(StringComparer.OrdinalIgnoreCase));
|
||||
approvedTools.TryAdd(normalizedApprovalCacheKey, 0);
|
||||
}
|
||||
|
||||
private PendingApprovalRequest GetPendingApproval(string approvalId)
|
||||
{
|
||||
if (_pendingApprovals.TryGetValue(approvalId, out PendingApprovalRequest? pending))
|
||||
{
|
||||
return pending;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Approval \"{approvalId}\" is not pending.");
|
||||
}
|
||||
|
||||
private static string RequireApprovalId(string? approvalId)
|
||||
{
|
||||
string? normalizedApprovalId = NormalizeOptionalString(approvalId);
|
||||
return normalizedApprovalId
|
||||
?? throw new InvalidOperationException("Approval ID is required.");
|
||||
}
|
||||
|
||||
private static PermissionRequestResultKind ParseDecision(string? decision)
|
||||
{
|
||||
return NormalizeOptionalString(decision)?.ToLowerInvariant() switch
|
||||
{
|
||||
ApprovedDecision => PermissionRequestResultKind.Approved,
|
||||
RejectedDecision => PermissionRequestResultKind.DeniedInteractivelyByUser,
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Unsupported approval decision \"{decision}\"."),
|
||||
};
|
||||
}
|
||||
|
||||
private static string CreateApprovalRequestId()
|
||||
{
|
||||
return $"approval-{Guid.NewGuid():N}";
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalPreviewText(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? NormalizeOptionalStringList(IEnumerable<string?> values)
|
||||
{
|
||||
List<string> normalized = values
|
||||
.Select(NormalizeOptionalString)
|
||||
.Where(static value => value is not null)
|
||||
.Cast<string>()
|
||||
.ToList();
|
||||
|
||||
return normalized.Count > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
private sealed record PendingApprovalRequest(
|
||||
string RequestId,
|
||||
string SessionId,
|
||||
string ApprovalId,
|
||||
string? ApprovalCacheKey,
|
||||
TaskCompletionSource<PermissionRequestResultKind> Decision);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class CopilotExitPlanModeCoordinator
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ExitPlanModeRequestedEventDto> _pendingExitPlanRequests =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
public ExitPlanModeRequestedEventDto RecordExitPlanModeRequest(
|
||||
RunTurnCommandDto command,
|
||||
WorkflowNodeDto agent,
|
||||
ExitPlanModeRequestedEvent request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
ExitPlanModeRequestedEventDto exitPlanEvent = BuildExitPlanModeRequestedEvent(command, agent, request);
|
||||
_pendingExitPlanRequests[command.RequestId] = exitPlanEvent;
|
||||
return exitPlanEvent;
|
||||
}
|
||||
|
||||
public ExitPlanModeRequestedEventDto? ConsumePendingRequest(string turnRequestId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(turnRequestId);
|
||||
return _pendingExitPlanRequests.TryRemove(turnRequestId, out ExitPlanModeRequestedEventDto? pending)
|
||||
? pending
|
||||
: null;
|
||||
}
|
||||
|
||||
internal static ExitPlanModeRequestedEventDto BuildExitPlanModeRequestedEvent(
|
||||
RunTurnCommandDto command,
|
||||
WorkflowNodeDto agent,
|
||||
ExitPlanModeRequestedEvent request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
ExitPlanModeRequestedData requestData = request.Data
|
||||
?? throw new InvalidOperationException("Exit plan mode request data is required.");
|
||||
|
||||
string exitPlanId = NormalizeOptionalString(requestData.RequestId)
|
||||
?? throw new InvalidOperationException("Exit plan mode request ID is required.");
|
||||
string? normalizedAgentId = NormalizeOptionalString(agent.GetAgentId());
|
||||
string? normalizedAgentName = NormalizeOptionalString(agent.GetAgentName()) ?? normalizedAgentId;
|
||||
|
||||
return new ExitPlanModeRequestedEventDto
|
||||
{
|
||||
Type = "exit-plan-mode-requested",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
ExitPlanId = exitPlanId,
|
||||
AgentId = normalizedAgentId,
|
||||
AgentName = normalizedAgentName,
|
||||
Summary = NormalizeOptionalString(requestData.Summary) ?? string.Empty,
|
||||
PlanContent = NormalizeOptionalString(requestData.PlanContent) ?? string.Empty,
|
||||
Actions = NormalizeOptionalStringList(requestData.Actions ?? []),
|
||||
RecommendedAction = NormalizeOptionalString(requestData.RecommendedAction),
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? NormalizeOptionalStringList(IEnumerable<string?> values)
|
||||
{
|
||||
List<string> normalized = values
|
||||
.Select(NormalizeOptionalString)
|
||||
.Where(static value => value is not null)
|
||||
.Cast<string>()
|
||||
.ToList();
|
||||
|
||||
return normalized.Count > 0 ? normalized : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal static class CopilotManagedSessionIds
|
||||
{
|
||||
private const string Prefix = "aryx::";
|
||||
private const string Separator = "::";
|
||||
|
||||
public static string Build(string aryxSessionId, string agentId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(aryxSessionId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(agentId);
|
||||
|
||||
return $"{Prefix}{Uri.EscapeDataString(aryxSessionId)}{Separator}{Uri.EscapeDataString(agentId)}";
|
||||
}
|
||||
|
||||
public static bool IsManagedByAryx(string copilotSessionId)
|
||||
=> TryParse(copilotSessionId, out _, out _);
|
||||
|
||||
public static bool IsManagedByAryx(string copilotSessionId, string aryxSessionId)
|
||||
{
|
||||
return TryParse(copilotSessionId, out string? parsedSessionId, out _)
|
||||
&& string.Equals(parsedSessionId, aryxSessionId, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public static bool TryParse(string? copilotSessionId, out string aryxSessionId, out string agentId)
|
||||
{
|
||||
aryxSessionId = string.Empty;
|
||||
agentId = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(copilotSessionId)
|
||||
|| !copilotSessionId.StartsWith(Prefix, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string payload = copilotSessionId[Prefix.Length..];
|
||||
string[] parts = payload.Split(Separator, StringSplitOptions.None);
|
||||
if (parts.Length != 2
|
||||
|| string.IsNullOrWhiteSpace(parts[0])
|
||||
|| string.IsNullOrWhiteSpace(parts[1]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
aryxSessionId = Uri.UnescapeDataString(parts[0]);
|
||||
agentId = Uri.UnescapeDataString(parts[1]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class CopilotMcpOAuthCoordinator
|
||||
{
|
||||
public McpOauthRequiredEventDto BuildMcpOauthRequiredEvent(
|
||||
RunTurnCommandDto command,
|
||||
WorkflowNodeDto agent,
|
||||
McpOauthRequiredEvent request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
McpOauthRequiredData requestData = request.Data
|
||||
?? throw new InvalidOperationException("MCP OAuth request data is required.");
|
||||
|
||||
string oauthRequestId = NormalizeOptionalString(requestData.RequestId)
|
||||
?? throw new InvalidOperationException("MCP OAuth request ID is required.");
|
||||
string? normalizedAgentId = NormalizeOptionalString(agent.GetAgentId());
|
||||
string? normalizedAgentName = NormalizeOptionalString(agent.GetAgentName()) ?? normalizedAgentId;
|
||||
|
||||
return new McpOauthRequiredEventDto
|
||||
{
|
||||
Type = "mcp-oauth-required",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
OauthRequestId = oauthRequestId,
|
||||
AgentId = normalizedAgentId,
|
||||
AgentName = normalizedAgentName,
|
||||
ServerName = NormalizeOptionalString(requestData.ServerName) ?? string.Empty,
|
||||
ServerUrl = NormalizeOptionalString(requestData.ServerUrl) ?? string.Empty,
|
||||
StaticClientConfig = BuildStaticClientConfig(requestData.StaticClientConfig),
|
||||
};
|
||||
}
|
||||
|
||||
private static McpOauthStaticClientConfigDto? BuildStaticClientConfig(
|
||||
McpOauthRequiredDataStaticClientConfig? staticClientConfig)
|
||||
{
|
||||
if (staticClientConfig is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new McpOauthStaticClientConfigDto
|
||||
{
|
||||
ClientId = NormalizeOptionalString(staticClientConfig.ClientId) ?? string.Empty,
|
||||
PublicClient = staticClientConfig.PublicClient,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed record CopilotMessageOptionsMetadata(string MessageMode);
|
||||
@@ -0,0 +1,381 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal static class CopilotSessionHooks
|
||||
{
|
||||
private const string AskUserToolName = "ask_user";
|
||||
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,
|
||||
WorkflowNodeDto agentDefinition,
|
||||
ResolvedHookSet? configuredHooks = null,
|
||||
IHookCommandRunner? hookCommandRunner = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
ArgumentNullException.ThrowIfNull(agentDefinition);
|
||||
ResolvedHookSet hooks = configuredHooks ?? ResolvedHookSet.Empty;
|
||||
IHookCommandRunner runner = hookCommandRunner ?? HookCommandRunner.Instance;
|
||||
|
||||
return new SessionHooks
|
||||
{
|
||||
OnPreToolUse = (input, _) => CreatePreToolUseOutputAsync(command, agentDefinition, hooks, runner, input),
|
||||
OnPostToolUse = (input, _) => RunPostToolUseHooksAsync(command, hooks, runner, input),
|
||||
OnUserPromptSubmitted = (input, _) => RunUserPromptSubmittedHooksAsync(command, hooks, runner, input),
|
||||
OnSessionStart = (input, _) => RunSessionStartHooksAsync(command, hooks, runner, input),
|
||||
OnSessionEnd = (input, _) => RunSessionEndHooksAsync(command, hooks, runner, input),
|
||||
OnErrorOccurred = (input, _) => RunErrorOccurredHooksAsync(command, hooks, runner, input),
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<PreToolUseHookOutput?> CreatePreToolUseOutputAsync(
|
||||
RunTurnCommandDto command,
|
||||
WorkflowNodeDto agentDefinition,
|
||||
ResolvedHookSet configuredHooks,
|
||||
IHookCommandRunner hookCommandRunner,
|
||||
PreToolUseHookInput input)
|
||||
{
|
||||
if (configuredHooks.PreToolUse.Count > 0)
|
||||
{
|
||||
string payload = SerializeHookInput(new FilePreToolUseHookInput
|
||||
{
|
||||
Timestamp = input.Timestamp,
|
||||
Cwd = input.Cwd,
|
||||
ToolName = input.ToolName,
|
||||
ToolArgs = SerializeHookValue(input.ToolArgs),
|
||||
});
|
||||
|
||||
foreach (HookCommandDefinition hook in configuredHooks.PreToolUse)
|
||||
{
|
||||
string? hookOutput = await hookCommandRunner.RunAsync(
|
||||
hook,
|
||||
payload,
|
||||
command.ProjectPath,
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
PreToolUseHookOutput? decision = ParsePreToolUseDecision(hookOutput);
|
||||
if (string.Equals(decision?.PermissionDecision, DenyDecision, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return decision;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return CreateApprovalPolicyOutput(command, agentDefinition, input);
|
||||
}
|
||||
|
||||
private static async Task<PostToolUseHookOutput?> RunPostToolUseHooksAsync(
|
||||
RunTurnCommandDto command,
|
||||
ResolvedHookSet configuredHooks,
|
||||
IHookCommandRunner hookCommandRunner,
|
||||
PostToolUseHookInput input)
|
||||
{
|
||||
await RunConfiguredHooksAsync(
|
||||
configuredHooks.PostToolUse,
|
||||
hookCommandRunner,
|
||||
command.ProjectPath,
|
||||
SerializeHookInput(new FilePostToolUseHookInput
|
||||
{
|
||||
Timestamp = input.Timestamp,
|
||||
Cwd = input.Cwd,
|
||||
ToolName = input.ToolName,
|
||||
ToolArgs = SerializeHookValue(input.ToolArgs),
|
||||
ToolResult = input.ToolResult,
|
||||
}))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<UserPromptSubmittedHookOutput?> RunUserPromptSubmittedHooksAsync(
|
||||
RunTurnCommandDto command,
|
||||
ResolvedHookSet configuredHooks,
|
||||
IHookCommandRunner hookCommandRunner,
|
||||
UserPromptSubmittedHookInput input)
|
||||
{
|
||||
await RunConfiguredHooksAsync(
|
||||
configuredHooks.UserPromptSubmitted,
|
||||
hookCommandRunner,
|
||||
command.ProjectPath,
|
||||
SerializeHookInput(new FileUserPromptSubmittedHookInput
|
||||
{
|
||||
Timestamp = input.Timestamp,
|
||||
Cwd = input.Cwd,
|
||||
Prompt = input.Prompt,
|
||||
}))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<SessionStartHookOutput?> RunSessionStartHooksAsync(
|
||||
RunTurnCommandDto command,
|
||||
ResolvedHookSet configuredHooks,
|
||||
IHookCommandRunner hookCommandRunner,
|
||||
SessionStartHookInput input)
|
||||
{
|
||||
await RunConfiguredHooksAsync(
|
||||
configuredHooks.SessionStart,
|
||||
hookCommandRunner,
|
||||
command.ProjectPath,
|
||||
SerializeHookInput(new FileSessionStartHookInput
|
||||
{
|
||||
Timestamp = input.Timestamp,
|
||||
Cwd = input.Cwd,
|
||||
Source = input.Source,
|
||||
InitialPrompt = input.InitialPrompt,
|
||||
}))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<SessionEndHookOutput?> RunSessionEndHooksAsync(
|
||||
RunTurnCommandDto command,
|
||||
ResolvedHookSet configuredHooks,
|
||||
IHookCommandRunner hookCommandRunner,
|
||||
SessionEndHookInput input)
|
||||
{
|
||||
await RunConfiguredHooksAsync(
|
||||
configuredHooks.SessionEnd,
|
||||
hookCommandRunner,
|
||||
command.ProjectPath,
|
||||
SerializeHookInput(new FileSessionEndHookInput
|
||||
{
|
||||
Timestamp = input.Timestamp,
|
||||
Cwd = input.Cwd,
|
||||
Reason = input.Reason,
|
||||
FinalMessage = input.FinalMessage,
|
||||
Error = input.Error,
|
||||
}))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<ErrorOccurredHookOutput?> RunErrorOccurredHooksAsync(
|
||||
RunTurnCommandDto command,
|
||||
ResolvedHookSet configuredHooks,
|
||||
IHookCommandRunner hookCommandRunner,
|
||||
ErrorOccurredHookInput input)
|
||||
{
|
||||
await RunConfiguredHooksAsync(
|
||||
configuredHooks.ErrorOccurred,
|
||||
hookCommandRunner,
|
||||
command.ProjectPath,
|
||||
SerializeHookInput(new FileErrorOccurredHookInput
|
||||
{
|
||||
Timestamp = input.Timestamp,
|
||||
Cwd = input.Cwd,
|
||||
Error = new FileHookError
|
||||
{
|
||||
Message = input.Error,
|
||||
Context = input.ErrorContext,
|
||||
Recoverable = input.Recoverable,
|
||||
},
|
||||
}))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task RunConfiguredHooksAsync(
|
||||
IReadOnlyList<HookCommandDefinition> hooks,
|
||||
IHookCommandRunner hookCommandRunner,
|
||||
string projectPath,
|
||||
string payload)
|
||||
{
|
||||
if (hooks.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (HookCommandDefinition hook in hooks)
|
||||
{
|
||||
await hookCommandRunner.RunAsync(
|
||||
hook,
|
||||
payload,
|
||||
projectPath,
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static PreToolUseHookOutput CreateApprovalPolicyOutput(
|
||||
RunTurnCommandDto command,
|
||||
WorkflowNodeDto agentDefinition,
|
||||
PreToolUseHookInput input)
|
||||
{
|
||||
string? toolName = Normalize(input.ToolName);
|
||||
if (IsAlwaysAllowedTool(toolName))
|
||||
{
|
||||
return new PreToolUseHookOutput
|
||||
{
|
||||
PermissionDecision = AllowDecision,
|
||||
};
|
||||
}
|
||||
|
||||
string? autoApprovedToolName = CopilotApprovalCoordinator.ResolveHookToolCategory(toolName) ?? toolName;
|
||||
string? mcpServerApprovalKey = CopilotApprovalCoordinator.ResolveHookMcpServerApprovalKey(
|
||||
toolName,
|
||||
command.Tooling?.McpServers);
|
||||
|
||||
bool requiresApproval = CopilotApprovalCoordinator.RequiresToolCallApproval(
|
||||
command.Workflow.Settings.ApprovalPolicy,
|
||||
agentDefinition.GetAgentId(),
|
||||
toolName,
|
||||
autoApprovedToolName,
|
||||
mcpServerApprovalKey);
|
||||
|
||||
return new PreToolUseHookOutput
|
||||
{
|
||||
PermissionDecision = requiresApproval ? AskDecision : AllowDecision,
|
||||
};
|
||||
}
|
||||
|
||||
private static PreToolUseHookOutput? ParsePreToolUseDecision(string? hookOutput)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hookOutput))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
FilePreToolUseHookOutput? parsed = JsonSerializer.Deserialize<FilePreToolUseHookOutput>(hookOutput, HookJsonOptions);
|
||||
if (!string.Equals(parsed?.PermissionDecision, DenyDecision, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PreToolUseHookOutput
|
||||
{
|
||||
PermissionDecision = DenyDecision,
|
||||
PermissionDecisionReason = Normalize(parsed?.PermissionDecisionReason),
|
||||
};
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Ignoring invalid preToolUse hook output: {exception.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string SerializeHookInput<T>(T input)
|
||||
=> JsonSerializer.Serialize(input, HookJsonOptions);
|
||||
|
||||
private static string SerializeHookValue(object? value)
|
||||
=> JsonSerializer.Serialize(value, HookJsonOptions);
|
||||
|
||||
private static JsonSerializerOptions CreateHookJsonOptions()
|
||||
{
|
||||
JsonSerializerOptions options = JsonSerialization.CreateWebOptions();
|
||||
options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
|
||||
return options;
|
||||
}
|
||||
|
||||
private static bool IsAlwaysAllowedTool(string? toolName)
|
||||
{
|
||||
string? normalizedToolName = Normalize(toolName);
|
||||
return normalizedToolName is not null
|
||||
&& (AlwaysAllowedToolNames.Contains(normalizedToolName)
|
||||
|| normalizedToolName.StartsWith(HandoffToolPrefix, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private sealed class FileSessionStartHookInput
|
||||
{
|
||||
public long Timestamp { get; init; }
|
||||
public string Cwd { get; init; } = string.Empty;
|
||||
public string Source { get; init; } = string.Empty;
|
||||
public string? InitialPrompt { get; init; }
|
||||
}
|
||||
|
||||
private sealed class FileSessionEndHookInput
|
||||
{
|
||||
public long Timestamp { get; init; }
|
||||
public string Cwd { get; init; } = string.Empty;
|
||||
public string Reason { get; init; } = string.Empty;
|
||||
public string? FinalMessage { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
private sealed class FileUserPromptSubmittedHookInput
|
||||
{
|
||||
public long Timestamp { get; init; }
|
||||
public string Cwd { get; init; } = string.Empty;
|
||||
public string Prompt { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class FilePreToolUseHookInput
|
||||
{
|
||||
public long Timestamp { get; init; }
|
||||
public string Cwd { get; init; } = string.Empty;
|
||||
public string ToolName { get; init; } = string.Empty;
|
||||
public string ToolArgs { get; init; } = "null";
|
||||
}
|
||||
|
||||
private sealed class FilePostToolUseHookInput
|
||||
{
|
||||
public long Timestamp { get; init; }
|
||||
public string Cwd { get; init; } = string.Empty;
|
||||
public string ToolName { get; init; } = string.Empty;
|
||||
public string ToolArgs { get; init; } = "null";
|
||||
public object? ToolResult { get; init; }
|
||||
}
|
||||
|
||||
private sealed class FileErrorOccurredHookInput
|
||||
{
|
||||
public long Timestamp { get; init; }
|
||||
public string Cwd { get; init; } = string.Empty;
|
||||
public FileHookError Error { get; init; } = new();
|
||||
}
|
||||
|
||||
private sealed class FileHookError
|
||||
{
|
||||
public string Message { get; init; } = string.Empty;
|
||||
public string Context { get; init; } = string.Empty;
|
||||
public bool Recoverable { get; init; }
|
||||
}
|
||||
|
||||
private sealed class FilePreToolUseHookOutput
|
||||
{
|
||||
public string? PermissionDecision { get; init; }
|
||||
public string? PermissionDecisionReason { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
using GitHub.Copilot.SDK.Rpc;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class CopilotSessionManager : ICopilotSessionManager
|
||||
{
|
||||
public async Task<IReadOnlyDictionary<string, QuotaSnapshotDto>> GetQuotaAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using CopilotClient client = await CreateStartedClientAsync(cancellationToken).ConfigureAwait(false);
|
||||
AccountGetQuotaResult result = await client.Rpc.Account.GetQuotaAsync(cancellationToken).ConfigureAwait(false);
|
||||
return QuotaSnapshotMapper.Map(result.QuotaSnapshots);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CopilotSessionInfoDto>> ListSessionsAsync(
|
||||
CopilotSessionListFilterDto? filter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using CopilotClient client = await CreateStartedClientAsync(cancellationToken).ConfigureAwait(false);
|
||||
List<SessionMetadata> sessions = await client.ListSessionsAsync(CreateFilter(filter), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return sessions
|
||||
.Select(MapSession)
|
||||
.OrderByDescending(session => session.ModifiedTime, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CopilotSessionInfoDto>> DeleteSessionsAsync(
|
||||
string? aryxSessionId,
|
||||
string? copilotSessionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string? normalizedAryxSessionId = Normalize(aryxSessionId);
|
||||
string? normalizedCopilotSessionId = Normalize(copilotSessionId);
|
||||
if (normalizedAryxSessionId is null && normalizedCopilotSessionId is null)
|
||||
{
|
||||
throw new InvalidOperationException("delete-session requires a sessionId or copilotSessionId.");
|
||||
}
|
||||
|
||||
await using CopilotClient client = await CreateStartedClientAsync(cancellationToken).ConfigureAwait(false);
|
||||
List<SessionMetadata> sessions = await client.ListSessionsAsync(null, cancellationToken).ConfigureAwait(false);
|
||||
List<CopilotSessionInfoDto> targets = sessions
|
||||
.Select(MapSession)
|
||||
.Where(session =>
|
||||
(normalizedCopilotSessionId is not null
|
||||
&& string.Equals(session.CopilotSessionId, normalizedCopilotSessionId, StringComparison.Ordinal))
|
||||
|| (normalizedAryxSessionId is not null
|
||||
&& string.Equals(session.SessionId, normalizedAryxSessionId, StringComparison.Ordinal)
|
||||
&& session.ManagedByAryx))
|
||||
.ToList();
|
||||
|
||||
if (targets.Count == 0 && normalizedCopilotSessionId is not null)
|
||||
{
|
||||
targets.Add(CreateUnknownSessionInfo(normalizedCopilotSessionId));
|
||||
}
|
||||
|
||||
foreach (CopilotSessionInfoDto target in targets)
|
||||
{
|
||||
await client.DeleteSessionAsync(target.CopilotSessionId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
private static async Task<CopilotClient> CreateStartedClientAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
CopilotClient client = new(CopilotCliPathResolver.CreateClientOptions());
|
||||
await client.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static SessionListFilter? CreateFilter(CopilotSessionListFilterDto? filter)
|
||||
{
|
||||
if (filter is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SessionListFilter
|
||||
{
|
||||
Cwd = Normalize(filter.Cwd),
|
||||
GitRoot = Normalize(filter.GitRoot),
|
||||
Repository = Normalize(filter.Repository),
|
||||
Branch = Normalize(filter.Branch),
|
||||
};
|
||||
}
|
||||
|
||||
private static CopilotSessionInfoDto MapSession(SessionMetadata session)
|
||||
{
|
||||
bool managedByAryx = CopilotManagedSessionIds.TryParse(
|
||||
session.SessionId,
|
||||
out string aryxSessionId,
|
||||
out string agentId);
|
||||
|
||||
return new CopilotSessionInfoDto
|
||||
{
|
||||
CopilotSessionId = session.SessionId,
|
||||
ManagedByAryx = managedByAryx,
|
||||
SessionId = managedByAryx ? aryxSessionId : null,
|
||||
AgentId = managedByAryx ? agentId : null,
|
||||
StartTime = session.StartTime.ToUniversalTime().ToString("O"),
|
||||
ModifiedTime = session.ModifiedTime.ToUniversalTime().ToString("O"),
|
||||
Summary = Normalize(session.Summary),
|
||||
IsRemote = session.IsRemote,
|
||||
Cwd = Normalize(session.Context?.Cwd),
|
||||
GitRoot = Normalize(session.Context?.GitRoot),
|
||||
Repository = Normalize(session.Context?.Repository),
|
||||
Branch = Normalize(session.Context?.Branch),
|
||||
};
|
||||
}
|
||||
|
||||
private static CopilotSessionInfoDto CreateUnknownSessionInfo(string copilotSessionId)
|
||||
{
|
||||
bool managedByAryx = CopilotManagedSessionIds.TryParse(
|
||||
copilotSessionId,
|
||||
out string aryxSessionId,
|
||||
out string agentId);
|
||||
|
||||
return new CopilotSessionInfoDto
|
||||
{
|
||||
CopilotSessionId = copilotSessionId,
|
||||
ManagedByAryx = managedByAryx,
|
||||
SessionId = managedByAryx ? aryxSessionId : null,
|
||||
AgentId = managedByAryx ? agentId : null,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class CopilotUserInputCoordinator
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, PendingUserInputRequest> _pendingUserInputs = new(StringComparer.Ordinal);
|
||||
|
||||
public Task ResolveUserInputAsync(
|
||||
ResolveUserInputCommandDto command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
|
||||
string userInputId = RequireUserInputId(command.UserInputId);
|
||||
PendingUserInputRequest pending = GetPendingUserInput(userInputId);
|
||||
UserInputResponse response = new()
|
||||
{
|
||||
Answer = command.Answer ?? string.Empty,
|
||||
WasFreeform = command.WasFreeform,
|
||||
};
|
||||
|
||||
if (!pending.Response.TrySetResult(response))
|
||||
{
|
||||
throw new InvalidOperationException($"User input request \"{userInputId}\" is no longer pending.");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task<UserInputResponse> RequestUserInputAsync(
|
||||
RunTurnCommandDto command,
|
||||
WorkflowNodeDto agent,
|
||||
UserInputRequest request,
|
||||
UserInputInvocation invocation,
|
||||
Func<UserInputRequestedEventDto, Task> onUserInput,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
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))
|
||||
{
|
||||
throw new InvalidOperationException($"User input request \"{pending.UserInputId}\" is already pending.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await onUserInput(BuildUserInputRequestedEvent(command, agentId, agentName, request, pending.UserInputId))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
using CancellationTokenRegistration registration = cancellationToken.Register(
|
||||
static state =>
|
||||
{
|
||||
((TaskCompletionSource<UserInputResponse>)state!)
|
||||
.TrySetCanceled();
|
||||
},
|
||||
pending.Response);
|
||||
|
||||
return await pending.Response.Task.ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pendingUserInputs.TryRemove(pending.UserInputId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private static PendingUserInputRequest CreatePendingUserInput(RunTurnCommandDto command)
|
||||
{
|
||||
return new PendingUserInputRequest(
|
||||
command.RequestId,
|
||||
command.SessionId,
|
||||
CreateUserInputRequestId(),
|
||||
new TaskCompletionSource<UserInputResponse>(TaskCreationOptions.RunContinuationsAsynchronously));
|
||||
}
|
||||
|
||||
private PendingUserInputRequest GetPendingUserInput(string userInputId)
|
||||
{
|
||||
if (_pendingUserInputs.TryGetValue(userInputId, out PendingUserInputRequest? pending))
|
||||
{
|
||||
return pending;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"User input request \"{userInputId}\" is not pending.");
|
||||
}
|
||||
|
||||
private static string RequireUserInputId(string? userInputId)
|
||||
{
|
||||
string? normalizedUserInputId = NormalizeOptionalString(userInputId);
|
||||
return normalizedUserInputId
|
||||
?? throw new InvalidOperationException("User input ID is required.");
|
||||
}
|
||||
|
||||
private static string CreateUserInputRequestId()
|
||||
{
|
||||
return $"user-input-{Guid.NewGuid():N}";
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? NormalizeOptionalStringList(IEnumerable<string?> values)
|
||||
{
|
||||
List<string> normalized = values
|
||||
.Select(NormalizeOptionalString)
|
||||
.Where(static value => value is not null)
|
||||
.Cast<string>()
|
||||
.ToList();
|
||||
|
||||
return normalized.Count > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
private sealed record PendingUserInputRequest(
|
||||
string RequestId,
|
||||
string SessionId,
|
||||
string UserInputId,
|
||||
TaskCompletionSource<UserInputResponse> Response);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
public interface IProviderSessionManager
|
||||
{
|
||||
Task<IReadOnlyList<CopilotSessionInfoDto>> ListSessionsAsync(
|
||||
CopilotSessionListFilterDto? filter,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<CopilotSessionInfoDto>> DeleteSessionsAsync(
|
||||
string? aryxSessionId,
|
||||
string? copilotSessionId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyDictionary<string, QuotaSnapshotDto>> GetQuotaAsync(
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface ICopilotSessionManager : IProviderSessionManager;
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.Text.Json;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK.Rpc;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal static class QuotaSnapshotMapper
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
|
||||
|
||||
public static Dictionary<string, QuotaSnapshotDto> Map(
|
||||
IReadOnlyDictionary<string, AccountGetQuotaResultQuotaSnapshotsValue>? snapshots)
|
||||
{
|
||||
Dictionary<string, QuotaSnapshotDto> mapped = new(StringComparer.Ordinal);
|
||||
if (snapshots is null)
|
||||
{
|
||||
return mapped;
|
||||
}
|
||||
|
||||
foreach ((string key, AccountGetQuotaResultQuotaSnapshotsValue snapshot) in snapshots)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
mapped[key.Trim()] = Map(snapshot);
|
||||
}
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
public static Dictionary<string, QuotaSnapshotDto>? MapOrNull(
|
||||
IReadOnlyDictionary<string, object>? snapshots)
|
||||
{
|
||||
if (snapshots is not { Count: > 0 })
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Dictionary<string, QuotaSnapshotDto> mapped = new(StringComparer.Ordinal);
|
||||
foreach ((string key, object snapshot) in snapshots)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
QuotaSnapshotDto? mappedSnapshot = TryMap(snapshot);
|
||||
if (mappedSnapshot is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
mapped[key.Trim()] = mappedSnapshot;
|
||||
}
|
||||
|
||||
return mapped.Count == 0 ? null : mapped;
|
||||
}
|
||||
|
||||
public static QuotaSnapshotDto Map(AccountGetQuotaResultQuotaSnapshotsValue snapshot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
|
||||
return new QuotaSnapshotDto
|
||||
{
|
||||
EntitlementRequests = snapshot.EntitlementRequests,
|
||||
UsedRequests = snapshot.UsedRequests,
|
||||
RemainingPercentage = snapshot.RemainingPercentage,
|
||||
Overage = snapshot.Overage,
|
||||
OverageAllowedWithExhaustedQuota = snapshot.OverageAllowedWithExhaustedQuota,
|
||||
ResetDate = snapshot.ResetDate,
|
||||
};
|
||||
}
|
||||
|
||||
private static QuotaSnapshotDto? TryMap(object? snapshot)
|
||||
{
|
||||
if (snapshot is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (snapshot is AccountGetQuotaResultQuotaSnapshotsValue typedSnapshot)
|
||||
{
|
||||
return Map(typedSnapshot);
|
||||
}
|
||||
|
||||
JsonElement element = snapshot is JsonElement jsonElement
|
||||
? jsonElement
|
||||
: JsonSerializer.SerializeToElement(snapshot, JsonOptions);
|
||||
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
AccountGetQuotaResultQuotaSnapshotsValue? deserialized =
|
||||
element.Deserialize<AccountGetQuotaResultQuotaSnapshotsValue>(JsonOptions);
|
||||
|
||||
return deserialized is null ? null : Map(deserialized);
|
||||
}
|
||||
|
||||
private static JsonSerializerOptions CreateJsonOptions()
|
||||
{
|
||||
JsonSerializerOptions options = JsonSerialization.CreateWebOptions();
|
||||
options.PropertyNameCaseInsensitive = true;
|
||||
return options;
|
||||
}
|
||||
}
|
||||
+122
-80
@@ -1,15 +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)
|
||||
@@ -26,9 +20,30 @@ internal static class WorkflowTranscriptProjector
|
||||
mapped.AuthorName = message.AuthorName;
|
||||
}
|
||||
|
||||
foreach (ChatMessageAttachmentDto attachment in message.Attachments)
|
||||
{
|
||||
mapped.Contents.Add(new AIContent
|
||||
{
|
||||
RawRepresentation = attachment,
|
||||
});
|
||||
}
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
public static void AttachMessageMode(IList<ChatMessage> messages, string? messageMode)
|
||||
{
|
||||
if (messages.Count == 0 || string.IsNullOrWhiteSpace(messageMode))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
messages[^1].Contents.Add(new AIContent
|
||||
{
|
||||
RawRepresentation = new CopilotMessageOptionsMetadata(messageMode.Trim()),
|
||||
});
|
||||
}
|
||||
|
||||
public static List<ChatMessageDto> ProjectCompletedMessages(
|
||||
RunTurnCommandDto command,
|
||||
IReadOnlyList<ChatMessage> newMessages,
|
||||
@@ -51,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();
|
||||
|
||||
@@ -62,9 +77,9 @@ internal static class WorkflowTranscriptProjector
|
||||
message,
|
||||
remainingSegments,
|
||||
assistantMessages.Count - messageIndex,
|
||||
command.Pattern,
|
||||
command.Workflow,
|
||||
fallbackAgent);
|
||||
string content = message.Text ?? matchedSegment?.Content ?? string.Empty;
|
||||
string content = ResolveProjectedContent(message, matchedSegment);
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
continue;
|
||||
@@ -106,10 +121,12 @@ internal static class WorkflowTranscriptProjector
|
||||
{
|
||||
return new ChatMessageDto
|
||||
{
|
||||
Id = matchedSegment?.MessageId ?? $"{command.RequestId}-final-{fallbackOutputIndex}",
|
||||
Id = matchedSegment?.MessageId
|
||||
?? message.MessageId
|
||||
?? $"{command.RequestId}-final-{fallbackOutputIndex}",
|
||||
Role = message.Role == ChatRole.System ? "system" : "assistant",
|
||||
AuthorName = ResolveProjectedAuthorName(
|
||||
command.Pattern,
|
||||
command.Workflow,
|
||||
message.AuthorName,
|
||||
matchedSegment?.AuthorName,
|
||||
fallbackAgent),
|
||||
@@ -118,6 +135,41 @@ internal static class WorkflowTranscriptProjector
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveProjectedContent(
|
||||
ChatMessage message,
|
||||
TranscriptSegment? matchedSegment)
|
||||
{
|
||||
if (matchedSegment is { IsFinalized: true } finalizedSegment
|
||||
&& !string.IsNullOrWhiteSpace(finalizedSegment.Content))
|
||||
{
|
||||
return finalizedSegment.Content;
|
||||
}
|
||||
|
||||
return FirstNonBlank(
|
||||
message.Text,
|
||||
TryGetAssistantMessageContent(message),
|
||||
matchedSegment?.Content)
|
||||
?? string.Empty;
|
||||
}
|
||||
|
||||
private static string? TryGetAssistantMessageContent(ChatMessage message)
|
||||
{
|
||||
if (TryGetAssistantMessageData(message.RawRepresentation, out AssistantMessageData? assistantMessageData))
|
||||
{
|
||||
return assistantMessageData?.Content;
|
||||
}
|
||||
|
||||
foreach (AIContent content in message.Contents)
|
||||
{
|
||||
if (TryGetAssistantMessageData(content.RawRepresentation, out assistantMessageData))
|
||||
{
|
||||
return assistantMessageData?.Content;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ChatMessageDto CreateProjectedMessageFromSegment(
|
||||
RunTurnCommandDto command,
|
||||
TranscriptSegment segment,
|
||||
@@ -127,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();
|
||||
@@ -152,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);
|
||||
}
|
||||
|
||||
@@ -166,7 +218,7 @@ internal static class WorkflowTranscriptProjector
|
||||
ChatMessage message,
|
||||
IReadOnlyList<TranscriptSegment> remainingSegments,
|
||||
int remainingMessageCount,
|
||||
PatternDefinitionDto pattern,
|
||||
WorkflowDefinitionDto workflow,
|
||||
AgentIdentity? fallbackAgent)
|
||||
{
|
||||
if (remainingSegments.Count == 0)
|
||||
@@ -174,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);
|
||||
@@ -187,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))
|
||||
@@ -211,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))
|
||||
@@ -329,7 +391,7 @@ internal static class WorkflowTranscriptProjector
|
||||
}
|
||||
|
||||
private static string ResolveProjectedAuthorName(
|
||||
PatternDefinitionDto pattern,
|
||||
WorkflowDefinitionDto workflow,
|
||||
string? primaryIdentifier,
|
||||
string? fallbackIdentifier,
|
||||
AgentIdentity? fallbackAgent)
|
||||
@@ -339,76 +401,56 @@ internal static class WorkflowTranscriptProjector
|
||||
return fallbackAgent.Value.AgentName;
|
||||
}
|
||||
|
||||
if (fallbackAgent.HasValue
|
||||
&& string.IsNullOrWhiteSpace(primaryIdentifier)
|
||||
&& string.IsNullOrWhiteSpace(fallbackIdentifier))
|
||||
{
|
||||
return fallbackAgent.Value.AgentName;
|
||||
}
|
||||
|
||||
IReadOnlyList<WorkflowNodeDto> agentNodes = workflow.GetAgentNodes();
|
||||
if (agentNodes.Count == 1
|
||||
&& string.IsNullOrWhiteSpace(primaryIdentifier)
|
||||
&& string.IsNullOrWhiteSpace(fallbackIdentifier))
|
||||
{
|
||||
WorkflowNodeDto singleAgent = agentNodes[0];
|
||||
return AgentIdentityResolver.ResolveDisplayAuthorName(workflow, singleAgent.GetAgentId(), singleAgent.GetAgentName());
|
||||
}
|
||||
|
||||
return AgentIdentityResolver.ResolveDisplayAuthorName(
|
||||
pattern,
|
||||
workflow,
|
||||
primaryIdentifier,
|
||||
fallbackIdentifier);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class StreamingTranscriptBuffer
|
||||
{
|
||||
private readonly List<BufferedTranscriptSegment> _segments = [];
|
||||
|
||||
public int Count => _segments.Count;
|
||||
|
||||
public TranscriptSegment AppendDelta(
|
||||
string messageId,
|
||||
string authorName,
|
||||
string delta)
|
||||
private static bool TryGetAssistantMessageData(
|
||||
object? rawRepresentation,
|
||||
out AssistantMessageData? assistantMessageData)
|
||||
{
|
||||
BufferedTranscriptSegment segment = GetOrCreateSegment(messageId, authorName);
|
||||
segment.SetContent(StreamingTextMerger.Merge(segment.Content.ToString(), delta));
|
||||
segment.SetAuthorName(authorName);
|
||||
return segment.ToSnapshot();
|
||||
switch (rawRepresentation)
|
||||
{
|
||||
case AssistantMessageEvent assistantMessage when !string.IsNullOrWhiteSpace(assistantMessage.Data.Content):
|
||||
assistantMessageData = assistantMessage.Data;
|
||||
return true;
|
||||
case AssistantMessageData data when !string.IsNullOrWhiteSpace(data.Content):
|
||||
assistantMessageData = data;
|
||||
return true;
|
||||
default:
|
||||
assistantMessageData = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<TranscriptSegment> Snapshot()
|
||||
private static string? FirstNonBlank(params string?[] values)
|
||||
{
|
||||
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)
|
||||
foreach (string? value in values)
|
||||
{
|
||||
return existing;
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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,58 +8,79 @@ 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";
|
||||
|
||||
private static readonly string[] AuthenticationErrorIndicators =
|
||||
[
|
||||
"login",
|
||||
"log in",
|
||||
"sign in",
|
||||
"authenticate",
|
||||
"authentication",
|
||||
"not signed in",
|
||||
"not logged in",
|
||||
"reauth",
|
||||
"credential",
|
||||
];
|
||||
private const string ResolveUserInputCommandType = "resolve-user-input";
|
||||
private const string ListSessionsCommandType = "list-sessions";
|
||||
private const string DeleteSessionCommandType = "delete-session";
|
||||
private const string DisconnectSessionCommandType = "disconnect-session";
|
||||
private const string GetQuotaCommandType = "get-quota";
|
||||
|
||||
private readonly Func<CancellationToken, Task<SidecarCapabilitiesDto>> _capabilitiesProvider;
|
||||
private readonly PatternValidator _patternValidator;
|
||||
private readonly WorkflowValidator _workflowValidator;
|
||||
private readonly ITurnWorkflowRunner _workflowRunner;
|
||||
private readonly IProviderSessionManager _sessionManager;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly IReadOnlyDictionary<string, Func<CommandContext, Task>> _commandHandlers;
|
||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||
private readonly ConcurrentDictionary<string, Task> _inFlight = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, CancellationTokenSource> _turnCancellations = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> _turnRequestIdsBySessionId =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
public SidecarProtocolHost()
|
||||
: this(new PatternValidator())
|
||||
: this(new WorkflowValidator())
|
||||
{
|
||||
}
|
||||
|
||||
public SidecarProtocolHost(
|
||||
PatternValidator patternValidator,
|
||||
ITurnWorkflowRunner? workflowRunner = null,
|
||||
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null)
|
||||
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null,
|
||||
IProviderSessionManager? sessionManager = null)
|
||||
: this(new WorkflowValidator(), workflowRunner, capabilitiesProvider, sessionManager)
|
||||
{
|
||||
_patternValidator = patternValidator;
|
||||
_workflowRunner = workflowRunner ?? new CopilotWorkflowRunner(_patternValidator);
|
||||
_capabilitiesProvider = capabilitiesProvider ?? BuildCapabilitiesAsync;
|
||||
_jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
[ResolveUserInputCommandType] = HandleResolveUserInputAsync,
|
||||
[ListSessionsCommandType] = HandleListSessionsAsync,
|
||||
[DeleteSessionCommandType] = HandleDeleteSessionAsync,
|
||||
[DisconnectSessionCommandType] = HandleDisconnectSessionAsync,
|
||||
[GetQuotaCommandType] = HandleGetQuotaAsync,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -148,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);
|
||||
}
|
||||
|
||||
@@ -171,13 +190,17 @@ public sealed class SidecarProtocolHost
|
||||
$"A turn with request ID '{context.Envelope.RequestId}' is already in progress.");
|
||||
}
|
||||
|
||||
RegisterTurnRequest(command.SessionId, context.Envelope.RequestId);
|
||||
try
|
||||
{
|
||||
IReadOnlyList<ChatMessageDto> messages = await _workflowRunner.RunTurnAsync(
|
||||
command,
|
||||
delta => WriteAsync(context.Output, delta, turnCancellation.Token),
|
||||
activity => WriteAsync(context.Output, activity, turnCancellation.Token),
|
||||
evt => WriteAsync(context.Output, evt, turnCancellation.Token),
|
||||
approval => WriteAsync(context.Output, approval, turnCancellation.Token),
|
||||
userInput => WriteAsync(context.Output, userInput, turnCancellation.Token),
|
||||
mcpOauth => WriteAsync(context.Output, mcpOauth, turnCancellation.Token),
|
||||
exitPlanMode => WriteAsync(context.Output, exitPlanMode, turnCancellation.Token),
|
||||
turnCancellation.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
@@ -204,6 +227,7 @@ public sealed class SidecarProtocolHost
|
||||
finally
|
||||
{
|
||||
_turnCancellations.TryRemove(context.Envelope.RequestId, out _);
|
||||
UnregisterTurnRequest(command.SessionId, context.Envelope.RequestId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +255,80 @@ public sealed class SidecarProtocolHost
|
||||
await _workflowRunner.ResolveApprovalAsync(command, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task HandleResolveUserInputAsync(CommandContext context)
|
||||
{
|
||||
ResolveUserInputCommandDto command = DeserializeCommand<ResolveUserInputCommandDto>(context);
|
||||
await _workflowRunner.ResolveUserInputAsync(command, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task HandleListSessionsAsync(CommandContext context)
|
||||
{
|
||||
ListSessionsCommandDto command = DeserializeCommand<ListSessionsCommandDto>(context);
|
||||
IReadOnlyList<CopilotSessionInfoDto> sessions = await _sessionManager.ListSessionsAsync(
|
||||
command.Filter,
|
||||
context.CancellationToken).ConfigureAwait(false);
|
||||
|
||||
await WriteAsync(context.Output, new SessionsListedEventDto
|
||||
{
|
||||
Type = "sessions-listed",
|
||||
RequestId = context.Envelope.RequestId,
|
||||
Sessions = sessions,
|
||||
}, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task HandleDeleteSessionAsync(CommandContext context)
|
||||
{
|
||||
DeleteSessionCommandDto command = DeserializeCommand<DeleteSessionCommandDto>(context);
|
||||
if (!string.IsNullOrWhiteSpace(command.SessionId))
|
||||
{
|
||||
CancelTurnRequestsForSession(command.SessionId);
|
||||
}
|
||||
|
||||
IReadOnlyList<CopilotSessionInfoDto> deletedSessions = await _sessionManager.DeleteSessionsAsync(
|
||||
command.SessionId,
|
||||
command.CopilotSessionId,
|
||||
context.CancellationToken).ConfigureAwait(false);
|
||||
|
||||
await WriteAsync(context.Output, new SessionsDeletedEventDto
|
||||
{
|
||||
Type = "sessions-deleted",
|
||||
RequestId = context.Envelope.RequestId,
|
||||
SessionId = string.IsNullOrWhiteSpace(command.SessionId) ? null : command.SessionId.Trim(),
|
||||
Sessions = deletedSessions,
|
||||
}, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task HandleDisconnectSessionAsync(CommandContext context)
|
||||
{
|
||||
DisconnectSessionCommandDto command = DeserializeCommand<DisconnectSessionCommandDto>(context);
|
||||
IReadOnlyList<string> cancelledRequestIds = CancelTurnRequestsForSession(command.SessionId);
|
||||
|
||||
await WriteAsync(context.Output, new SessionDisconnectedEventDto
|
||||
{
|
||||
Type = "session-disconnected",
|
||||
RequestId = context.Envelope.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
CancelledRequestIds = cancelledRequestIds,
|
||||
}, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task HandleGetQuotaAsync(CommandContext context)
|
||||
{
|
||||
_ = DeserializeCommand<GetQuotaCommandDto>(context);
|
||||
IReadOnlyDictionary<string, QuotaSnapshotDto> quotaSnapshots =
|
||||
await _sessionManager.GetQuotaAsync(context.CancellationToken).ConfigureAwait(false);
|
||||
|
||||
await WriteAsync(context.Output, new AccountQuotaResultEventDto
|
||||
{
|
||||
Type = "quota-result",
|
||||
RequestId = context.Envelope.RequestId,
|
||||
QuotaSnapshots = quotaSnapshots.ToDictionary(
|
||||
snapshot => snapshot.Key,
|
||||
snapshot => snapshot.Value,
|
||||
StringComparer.Ordinal),
|
||||
}, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private TCommand DeserializeCommand<TCommand>(CommandContext context)
|
||||
where TCommand : SidecarCommandEnvelope
|
||||
{
|
||||
@@ -291,229 +389,65 @@ public sealed class SidecarProtocolHost
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<SidecarCapabilitiesDto> BuildCapabilitiesAsync(CancellationToken cancellationToken)
|
||||
private void RegisterTurnRequest(string sessionId, string requestId)
|
||||
{
|
||||
try
|
||||
if (string.IsNullOrWhiteSpace(sessionId) || string.IsNullOrWhiteSpace(requestId))
|
||||
{
|
||||
CopilotCliContext cliContext = CopilotCliPathResolver.ResolveCliContext();
|
||||
CapabilityProbeResult probe = await ProbeCapabilitiesAsync(cliContext, cancellationToken).ConfigureAwait(false);
|
||||
return CreateCapabilities(probe.Models, probe.RuntimeTools, probe.Connection);
|
||||
return;
|
||||
}
|
||||
catch (Exception exception)
|
||||
|
||||
ConcurrentDictionary<string, byte> requestIds = _turnRequestIdsBySessionId.GetOrAdd(
|
||||
sessionId.Trim(),
|
||||
static _ => new ConcurrentDictionary<string, byte>(StringComparer.Ordinal));
|
||||
requestIds[requestId.Trim()] = 0;
|
||||
}
|
||||
|
||||
private void UnregisterTurnRequest(string sessionId, string requestId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId) || string.IsNullOrWhiteSpace(requestId))
|
||||
{
|
||||
SidecarConnectionDiagnosticsDto connection = CreateMissingCliDiagnostics(exception);
|
||||
Console.Error.WriteLine($"[aryx sidecar] {connection.Summary} {exception.Message}");
|
||||
return CreateCapabilities([], [], connection);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_turnRequestIdsBySessionId.TryGetValue(sessionId.Trim(), out ConcurrentDictionary<string, byte>? requestIds))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
requestIds.TryRemove(requestId.Trim(), out _);
|
||||
if (requestIds.IsEmpty)
|
||||
{
|
||||
_turnRequestIdsBySessionId.TryRemove(sessionId.Trim(), out _);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<CapabilityProbeResult> ProbeCapabilitiesAsync(
|
||||
CopilotCliContext cliContext,
|
||||
CancellationToken cancellationToken)
|
||||
private IReadOnlyList<string> CancelTurnRequestsForSession(string sessionId)
|
||||
{
|
||||
IReadOnlyList<SidecarModelCapabilityDto> models = [];
|
||||
IReadOnlyList<SidecarRuntimeToolDto> runtimeTools = [];
|
||||
SidecarCopilotAccountDiagnosticsDto? account = null;
|
||||
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null;
|
||||
Task<SidecarCopilotCliVersionDiagnosticsDto> cliVersionTask =
|
||||
CopilotConnectionMetadataResolver.GetCliVersionDiagnosticsAsync(cliContext, cancellationToken);
|
||||
|
||||
try
|
||||
if (string.IsNullOrWhiteSpace(sessionId)
|
||||
|| !_turnRequestIdsBySessionId.TryGetValue(sessionId.Trim(), out ConcurrentDictionary<string, byte>? requestIds))
|
||||
{
|
||||
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 result.Tools
|
||||
.Where(tool => !string.IsNullOrWhiteSpace(tool.Name))
|
||||
.Select(tool => new SidecarRuntimeToolDto
|
||||
List<string> cancelledRequestIds = [];
|
||||
foreach (string requestId in requestIds.Keys)
|
||||
{
|
||||
if (!_turnCancellations.TryGetValue(requestId, out CancellationTokenSource? turnCancellation))
|
||||
{
|
||||
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();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
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";
|
||||
try
|
||||
{
|
||||
turnCancellation.Cancel();
|
||||
cancelledRequestIds.Add(requestId);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return "copilot-error";
|
||||
return cancelledRequestIds;
|
||||
}
|
||||
|
||||
private sealed record CommandContext(
|
||||
@@ -521,9 +455,4 @@ public sealed class SidecarProtocolHost
|
||||
SidecarCommandEnvelope Envelope,
|
||||
TextWriter Output,
|
||||
CancellationToken CancellationToken);
|
||||
|
||||
private sealed record CapabilityProbeResult(
|
||||
IReadOnlyList<SidecarModelCapabilityDto> Models,
|
||||
IReadOnlyList<SidecarRuntimeToolDto> RuntimeTools,
|
||||
SidecarConnectionDiagnosticsDto Connection);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal static partial class StreamingTextMerger
|
||||
@@ -7,7 +5,6 @@ internal static partial class StreamingTextMerger
|
||||
private const double SnapshotReplacementMinLengthRatio = 0.6;
|
||||
private const int SnapshotReplacementMinTokenCount = 3;
|
||||
private const double SnapshotReplacementSharedTokenRatio = 0.5;
|
||||
private const string CharactersThatDoNotNeedLeadingSpace = "([{/\"'`";
|
||||
|
||||
public static string Merge(string current, string incoming)
|
||||
{
|
||||
@@ -32,51 +29,7 @@ internal static partial class StreamingTextMerger
|
||||
return incoming;
|
||||
}
|
||||
|
||||
return current + ResolveBoundarySeparator(current, incoming) + incoming;
|
||||
}
|
||||
|
||||
private static bool TryMergeSnapshotVariants(string current, string incoming, out string merged)
|
||||
{
|
||||
if (incoming.StartsWith(current, StringComparison.Ordinal)
|
||||
|| incoming.Contains(current, StringComparison.Ordinal))
|
||||
{
|
||||
merged = incoming;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (current.Contains(incoming, StringComparison.Ordinal))
|
||||
{
|
||||
merged = current;
|
||||
return true;
|
||||
}
|
||||
|
||||
merged = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryMergeByOverlap(string current, string incoming, out string merged)
|
||||
{
|
||||
int overlapLength = ComputeSuffixPrefixOverlap(current, incoming);
|
||||
if (overlapLength == 0)
|
||||
{
|
||||
merged = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
merged = current + incoming[overlapLength..];
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ResolveBoundarySeparator(string current, string incoming)
|
||||
{
|
||||
if (ShouldInsertNewlineBoundary(current, incoming))
|
||||
{
|
||||
return "\n";
|
||||
}
|
||||
|
||||
return ShouldInsertSpaceBoundary(current, incoming)
|
||||
? " "
|
||||
: string.Empty;
|
||||
return current + incoming;
|
||||
}
|
||||
|
||||
private static int ComputeSuffixPrefixOverlap(string current, string incoming)
|
||||
@@ -125,54 +78,6 @@ internal static partial class StreamingTextMerger
|
||||
&& incomingTokens.Count >= SnapshotReplacementMinTokenCount;
|
||||
}
|
||||
|
||||
private static bool ShouldInsertNewlineBoundary(string current, string incoming)
|
||||
{
|
||||
return !current.EndsWith('\n')
|
||||
&& MarkdownBlockPrefixRegex().IsMatch(incoming.TrimStart());
|
||||
}
|
||||
|
||||
private static bool ShouldInsertSpaceBoundary(string current, string incoming)
|
||||
{
|
||||
char lastCharacter = current[^1];
|
||||
char firstCharacter = incoming[0];
|
||||
if (HasExistingBoundary(lastCharacter, firstCharacter)
|
||||
|| CharactersThatDoNotNeedLeadingSpace.Contains(lastCharacter))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ClosingPunctuationRegex().IsMatch(incoming))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return StartsLikeASeparatedInlineFragment(firstCharacter, incoming)
|
||||
|| LooksLikeWordBoundary(current, incoming);
|
||||
}
|
||||
|
||||
private static bool HasExistingBoundary(char lastCharacter, char firstCharacter)
|
||||
{
|
||||
return char.IsWhiteSpace(lastCharacter) || char.IsWhiteSpace(firstCharacter);
|
||||
}
|
||||
|
||||
private static bool StartsLikeASeparatedInlineFragment(char firstCharacter, string incoming)
|
||||
{
|
||||
return MarkdownInlinePrefixRegex().IsMatch(incoming)
|
||||
|| char.IsUpper(firstCharacter)
|
||||
|| char.IsDigit(firstCharacter);
|
||||
}
|
||||
|
||||
private static bool LooksLikeWordBoundary(string current, string incoming)
|
||||
{
|
||||
string[] currentTokens = Tokenize(current).ToArray();
|
||||
string[] incomingTokens = Tokenize(incoming).ToArray();
|
||||
string firstIncomingToken = incomingTokens.FirstOrDefault() ?? string.Empty;
|
||||
|
||||
return currentTokens.Length >= 2
|
||||
&& incomingTokens.Length >= 2
|
||||
&& firstIncomingToken.Length >= 2;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> Tokenize(string value)
|
||||
{
|
||||
return TokenRegex()
|
||||
@@ -181,15 +86,38 @@ internal static partial class StreamingTextMerger
|
||||
.Where(token => token.Length > 0);
|
||||
}
|
||||
|
||||
[GeneratedRegex("[a-z0-9]+", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex TokenRegex();
|
||||
private static bool TryMergeSnapshotVariants(string current, string incoming, out string merged)
|
||||
{
|
||||
if (incoming.StartsWith(current, StringComparison.Ordinal)
|
||||
|| incoming.Contains(current, StringComparison.Ordinal))
|
||||
{
|
||||
merged = incoming;
|
||||
return true;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^[.,!?;:%)\]}]")]
|
||||
private static partial Regex ClosingPunctuationRegex();
|
||||
if (current.Contains(incoming, StringComparison.Ordinal))
|
||||
{
|
||||
merged = current;
|
||||
return true;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^[*_`~\[]")]
|
||||
private static partial Regex MarkdownInlinePrefixRegex();
|
||||
merged = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^(?:#{1,6}\s|[-*+]\s|\d+\.\s|>\s|```)", RegexOptions.Singleline)]
|
||||
private static partial Regex MarkdownBlockPrefixRegex();
|
||||
private static bool TryMergeByOverlap(string current, string incoming, out string merged)
|
||||
{
|
||||
int overlapLength = ComputeSuffixPrefixOverlap(current, incoming);
|
||||
if (overlapLength == 0)
|
||||
{
|
||||
merged = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
merged = current + incoming[overlapLength..];
|
||||
return true;
|
||||
}
|
||||
|
||||
[System.Text.RegularExpressions.GeneratedRegex("[a-z0-9]+", System.Text.RegularExpressions.RegexOptions.IgnoreCase)]
|
||||
private static partial System.Text.RegularExpressions.Regex TokenRegex();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
using System.Text;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal readonly record struct TranscriptSegment(
|
||||
string MessageId,
|
||||
string AuthorName,
|
||||
string Content,
|
||||
bool IsFinalized = false)
|
||||
{
|
||||
public static TranscriptSegment FromTuple((string MessageId, string AuthorName, string Content) segment)
|
||||
=> new(segment.MessageId, segment.AuthorName, segment.Content);
|
||||
|
||||
public void Deconstruct(out string messageId, out string authorName, out string content)
|
||||
{
|
||||
messageId = MessageId;
|
||||
authorName = AuthorName;
|
||||
content = Content;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class StreamingTranscriptBuffer
|
||||
{
|
||||
private readonly List<BufferedTranscriptSegment> _segments = [];
|
||||
|
||||
public int Count => _segments.Count;
|
||||
|
||||
public TranscriptSegment AppendDelta(
|
||||
string messageId,
|
||||
string authorName,
|
||||
string delta)
|
||||
{
|
||||
_ = TryAppendDelta(messageId, authorName, delta, out TranscriptSegment segment);
|
||||
return segment;
|
||||
}
|
||||
|
||||
public bool TryAppendDelta(
|
||||
string messageId,
|
||||
string authorName,
|
||||
string delta,
|
||||
out TranscriptSegment segment)
|
||||
{
|
||||
BufferedTranscriptSegment bufferedSegment = GetOrCreateSegment(messageId, authorName);
|
||||
bool contentChanged = bufferedSegment.TryAppendDelta(authorName, delta);
|
||||
segment = bufferedSegment.ToSnapshot();
|
||||
return contentChanged;
|
||||
}
|
||||
|
||||
public bool TryApplySnapshot(
|
||||
string messageId,
|
||||
string authorName,
|
||||
string content,
|
||||
out TranscriptSegment segment)
|
||||
{
|
||||
BufferedTranscriptSegment bufferedSegment = GetOrCreateSegment(messageId, authorName);
|
||||
bool visibleContentChanged = bufferedSegment.TryApplySnapshot(authorName, content);
|
||||
segment = bufferedSegment.ToSnapshot();
|
||||
return visibleContentChanged;
|
||||
}
|
||||
|
||||
public IReadOnlyList<TranscriptSegment> Snapshot()
|
||||
{
|
||||
return _segments.Select(segment => segment.ToSnapshot()).ToList();
|
||||
}
|
||||
|
||||
private BufferedTranscriptSegment GetOrCreateSegment(string messageId, string authorName)
|
||||
{
|
||||
BufferedTranscriptSegment? existing = _segments.LastOrDefault(segment => segment.MessageId == messageId);
|
||||
if (existing is not null)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
BufferedTranscriptSegment created = new(messageId, authorName);
|
||||
_segments.Add(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
private sealed class BufferedTranscriptSegment
|
||||
{
|
||||
public BufferedTranscriptSegment(string messageId, string authorName)
|
||||
{
|
||||
MessageId = messageId;
|
||||
AuthorName = authorName;
|
||||
}
|
||||
|
||||
public string MessageId { get; }
|
||||
|
||||
public string AuthorName { get; private set; }
|
||||
|
||||
public bool IsFinalized { get; private set; }
|
||||
|
||||
public StringBuilder Content { get; } = new();
|
||||
|
||||
public bool TryAppendDelta(string authorName, string delta)
|
||||
{
|
||||
SetAuthorName(authorName);
|
||||
if (IsFinalized || string.IsNullOrEmpty(delta))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string currentContent = Content.ToString();
|
||||
string mergedContent = StreamingTextMerger.Merge(currentContent, delta);
|
||||
if (string.Equals(currentContent, mergedContent, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
SetContent(mergedContent);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryApplySnapshot(string authorName, string content)
|
||||
{
|
||||
SetAuthorName(authorName);
|
||||
string normalizedContent = content ?? string.Empty;
|
||||
string currentContent = Content.ToString();
|
||||
bool visibleContentChanged = !string.Equals(currentContent, normalizedContent, StringComparison.Ordinal);
|
||||
|
||||
SetContent(normalizedContent);
|
||||
IsFinalized = true;
|
||||
return visibleContentChanged;
|
||||
}
|
||||
|
||||
private void SetContent(string value)
|
||||
{
|
||||
Content.Clear();
|
||||
Content.Append(value);
|
||||
}
|
||||
|
||||
private void SetAuthorName(string value)
|
||||
{
|
||||
AuthorName = value;
|
||||
}
|
||||
|
||||
public TranscriptSegment ToSnapshot()
|
||||
{
|
||||
return new TranscriptSegment(MessageId, AuthorName, Content.ToString(), IsFinalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class ToolCallRegistry
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ProviderToolExecutionSnapshot> _toolExecutionsByCallId = new(StringComparer.Ordinal);
|
||||
|
||||
public bool TryGetExecution(string? toolCallId, [NotNullWhen(true)] out ProviderToolExecutionSnapshot? snapshot)
|
||||
{
|
||||
snapshot = null;
|
||||
return !string.IsNullOrWhiteSpace(toolCallId)
|
||||
&& _toolExecutionsByCallId.TryGetValue(toolCallId, out snapshot);
|
||||
}
|
||||
|
||||
public bool TryGetToolName(string? toolCallId, [NotNullWhen(true)] out string? toolName)
|
||||
{
|
||||
toolName = null;
|
||||
return TryGetExecution(toolCallId, out ProviderToolExecutionSnapshot? snapshot)
|
||||
&& !string.IsNullOrWhiteSpace(snapshot.ToolName)
|
||||
&& (toolName = snapshot.ToolName) is not null;
|
||||
}
|
||||
|
||||
public bool HasTrackedArguments(string? toolCallId)
|
||||
{
|
||||
return TryGetExecution(toolCallId, out ProviderToolExecutionSnapshot? snapshot)
|
||||
&& snapshot.ToolArguments is { Count: > 0 };
|
||||
}
|
||||
|
||||
public void RecordToolStart(
|
||||
string toolCallId,
|
||||
string toolName,
|
||||
IReadOnlyDictionary<string, object?>? toolArguments)
|
||||
{
|
||||
_toolExecutionsByCallId.AddOrUpdate(
|
||||
toolCallId,
|
||||
static (id, state) => new ProviderToolExecutionSnapshot
|
||||
{
|
||||
ToolCallId = id,
|
||||
ToolName = state.ToolName,
|
||||
ToolArguments = state.ToolArguments,
|
||||
Status = ProviderToolExecutionStatus.Running,
|
||||
},
|
||||
static (_, existing, state) => existing with
|
||||
{
|
||||
ToolName = state.ToolName,
|
||||
ToolArguments = state.ToolArguments,
|
||||
Status = ProviderToolExecutionStatus.Running,
|
||||
},
|
||||
(ToolName: toolName, ToolArguments: toolArguments));
|
||||
}
|
||||
|
||||
public bool TryRecordToolRequest(
|
||||
string? toolCallId,
|
||||
string toolName,
|
||||
IReadOnlyDictionary<string, object?>? toolArguments)
|
||||
{
|
||||
bool hasToolArguments = toolArguments is { Count: > 0 };
|
||||
string? normalizedToolCallId = NormalizeOptionalString(toolCallId);
|
||||
if (normalizedToolCallId is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_toolExecutionsByCallId.TryGetValue(normalizedToolCallId, out ProviderToolExecutionSnapshot? existing))
|
||||
{
|
||||
bool trackedHasArguments = existing.ToolArguments is { Count: > 0 };
|
||||
if (trackedHasArguments || !hasToolArguments)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_toolExecutionsByCallId.AddOrUpdate(
|
||||
normalizedToolCallId,
|
||||
id => new ProviderToolExecutionSnapshot
|
||||
{
|
||||
ToolCallId = id,
|
||||
ToolName = toolName,
|
||||
ToolArguments = toolArguments,
|
||||
Status = ProviderToolExecutionStatus.Running,
|
||||
},
|
||||
(_, existing) => existing with
|
||||
{
|
||||
ToolName = toolName,
|
||||
ToolArguments = toolArguments,
|
||||
Status = existing.Status is ProviderToolExecutionStatus.Completed or ProviderToolExecutionStatus.Failed
|
||||
? existing.Status
|
||||
: ProviderToolExecutionStatus.Running,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
public void RecordProgress(string toolCallId, string? progressMessage)
|
||||
{
|
||||
string? normalizedProgress = NormalizeOptionalString(progressMessage);
|
||||
_toolExecutionsByCallId.AddOrUpdate(
|
||||
toolCallId,
|
||||
id => new ProviderToolExecutionSnapshot
|
||||
{
|
||||
ToolCallId = id,
|
||||
Status = ProviderToolExecutionStatus.Running,
|
||||
LatestProgressMessage = normalizedProgress,
|
||||
},
|
||||
(_, existing) => existing with
|
||||
{
|
||||
Status = existing.Status is ProviderToolExecutionStatus.Completed or ProviderToolExecutionStatus.Failed
|
||||
? existing.Status
|
||||
: ProviderToolExecutionStatus.Running,
|
||||
LatestProgressMessage = normalizedProgress ?? existing.LatestProgressMessage,
|
||||
});
|
||||
}
|
||||
|
||||
public void RecordPartialResult(string toolCallId, string? partialOutput)
|
||||
{
|
||||
if (string.IsNullOrEmpty(partialOutput))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_toolExecutionsByCallId.AddOrUpdate(
|
||||
toolCallId,
|
||||
id => new ProviderToolExecutionSnapshot
|
||||
{
|
||||
ToolCallId = id,
|
||||
Status = ProviderToolExecutionStatus.Running,
|
||||
PartialOutput = partialOutput,
|
||||
},
|
||||
(_, existing) => existing with
|
||||
{
|
||||
Status = existing.Status is ProviderToolExecutionStatus.Completed or ProviderToolExecutionStatus.Failed
|
||||
? existing.Status
|
||||
: ProviderToolExecutionStatus.Running,
|
||||
PartialOutput = string.Concat(existing.PartialOutput, partialOutput),
|
||||
});
|
||||
}
|
||||
|
||||
public void RecordCompletion(ProviderToolExecutionCompleteEvent toolExecution)
|
||||
{
|
||||
_toolExecutionsByCallId.AddOrUpdate(
|
||||
toolExecution.ToolCallId,
|
||||
id => new ProviderToolExecutionSnapshot
|
||||
{
|
||||
ToolCallId = id,
|
||||
Status = toolExecution.Success ? ProviderToolExecutionStatus.Completed : ProviderToolExecutionStatus.Failed,
|
||||
ResultContent = toolExecution.ResultContent,
|
||||
DetailedResultContent = toolExecution.DetailedResultContent,
|
||||
Error = toolExecution.Error,
|
||||
},
|
||||
(_, existing) => existing with
|
||||
{
|
||||
Status = toolExecution.Success ? ProviderToolExecutionStatus.Completed : ProviderToolExecutionStatus.Failed,
|
||||
ResultContent = toolExecution.ResultContent ?? existing.ResultContent,
|
||||
DetailedResultContent = toolExecution.DetailedResultContent ?? existing.DetailedResultContent,
|
||||
Error = toolExecution.Error ?? existing.Error,
|
||||
});
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,929 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal class TurnExecutionState
|
||||
{
|
||||
private readonly RunTurnCommandDto _command;
|
||||
private readonly IReadOnlyDictionary<string, WorkflowDefinitionDto> _workflowLibrary;
|
||||
private readonly IReadOnlyDictionary<string, SubworkflowContext> _agentSubworkflowIndex;
|
||||
private readonly HashSet<string> _startedAgents = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _reclassifiedMessageIds = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentQueue<SidecarEventDto> _pendingEvents = new();
|
||||
private readonly ConcurrentQueue<McpOauthRequiredEventDto> _pendingMcpOauthRequests = new();
|
||||
private readonly ConcurrentDictionary<string, AgentIdentity> _observedAgentsByMessageId = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, ProviderReasoningSnapshot> _reasoningById = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, string> _latestIntentByAgentId = new(StringComparer.Ordinal);
|
||||
private readonly StreamingTranscriptBuffer _transcriptBuffer = new();
|
||||
private int _fallbackMessageIndex;
|
||||
private string? _lastObservedMessageId;
|
||||
|
||||
public TurnExecutionState(RunTurnCommandDto command)
|
||||
{
|
||||
_command = command;
|
||||
_workflowLibrary = WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(command.WorkflowLibrary);
|
||||
_agentSubworkflowIndex = AgentIdentityResolver.BuildAgentSubworkflowIndex(command.Workflow, _workflowLibrary);
|
||||
}
|
||||
|
||||
public ToolCallRegistry ToolCalls { get; } = new();
|
||||
|
||||
public AgentIdentity? ActiveAgent { get; private set; }
|
||||
|
||||
public List<ChatMessageDto> CompletedMessages { get; private set; } = [];
|
||||
|
||||
public bool HasPendingExitPlanModeRequest { get; private set; }
|
||||
|
||||
public ProviderTurnStreamCapabilities StreamCapabilities { get; private set; } = ProviderTurnStreamCapabilities.None;
|
||||
|
||||
public string? CurrentProviderTurnId { get; private set; }
|
||||
|
||||
public string? LatestCompletedProviderTurnId { get; private set; }
|
||||
|
||||
public bool SuppressHookLifecycleEvents { get; set; }
|
||||
|
||||
public void SetStreamCapabilities(ProviderTurnStreamCapabilities capabilities)
|
||||
{
|
||||
StreamCapabilities = capabilities ?? throw new ArgumentNullException(nameof(capabilities));
|
||||
}
|
||||
|
||||
public AgentIdentity ResolveAgentIdentity(string? agentId, string? agentName)
|
||||
{
|
||||
return AgentIdentityResolver.ResolveAgentIdentity(
|
||||
_command.Workflow,
|
||||
_workflowLibrary,
|
||||
agentId,
|
||||
agentName,
|
||||
_agentSubworkflowIndex);
|
||||
}
|
||||
|
||||
public bool TryResolveKnownAgentIdentity(string? agentIdentifier, out AgentIdentity agent)
|
||||
{
|
||||
return AgentIdentityResolver.TryResolveKnownAgentIdentity(
|
||||
_command.Workflow,
|
||||
_workflowLibrary,
|
||||
agentIdentifier,
|
||||
_agentSubworkflowIndex,
|
||||
out agent);
|
||||
}
|
||||
|
||||
public bool TryResolveObservedAgentIdentity(
|
||||
string? agentIdentifier,
|
||||
AgentIdentity? fallbackAgent,
|
||||
out AgentIdentity agent)
|
||||
{
|
||||
return AgentIdentityResolver.TryResolveObservedAgentIdentity(
|
||||
_command.Workflow,
|
||||
_workflowLibrary,
|
||||
agentIdentifier,
|
||||
fallbackAgent,
|
||||
_agentSubworkflowIndex,
|
||||
out agent);
|
||||
}
|
||||
|
||||
public bool TryCreateSubworkflowLifecycleActivity(
|
||||
string activityType,
|
||||
string? executorId,
|
||||
out AgentActivityEventDto activity)
|
||||
{
|
||||
activity = default!;
|
||||
if (!AgentIdentityResolver.TryResolveSubworkflowContext(
|
||||
_command.Workflow,
|
||||
_workflowLibrary,
|
||||
executorId,
|
||||
out SubworkflowContext subworkflow))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
activity = CreateSubworkflowActivity(activityType, subworkflow);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task EmitThinkingIfNeeded(
|
||||
AgentIdentity agent,
|
||||
Func<SidecarEventDto, Task> onEvent)
|
||||
{
|
||||
AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent);
|
||||
if (thinkingActivity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await onEvent(thinkingActivity).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public void QueueThinkingIfNeeded(AgentIdentity agent)
|
||||
{
|
||||
AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent);
|
||||
if (thinkingActivity is not null)
|
||||
{
|
||||
_pendingEvents.Enqueue(thinkingActivity);
|
||||
}
|
||||
}
|
||||
|
||||
public void QueueCompletedActivity(AgentIdentity agent)
|
||||
{
|
||||
_pendingEvents.Enqueue(CreateCompletedActivity(agent));
|
||||
}
|
||||
|
||||
public void ApplyEvent(SidecarEventDto evt)
|
||||
{
|
||||
if (evt is AgentActivityEventDto activity
|
||||
&& string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal)
|
||||
&& !string.IsNullOrWhiteSpace(activity.AgentId)
|
||||
&& !string.IsNullOrWhiteSpace(activity.AgentName))
|
||||
{
|
||||
ActiveAgent = ResolveAgentIdentity(activity.AgentId, activity.AgentName);
|
||||
}
|
||||
}
|
||||
|
||||
public void ObserveSessionEvent(WorkflowNodeDto agentDefinition, ProviderSessionEvent sessionEvent)
|
||||
{
|
||||
AgentIdentity agent = ResolveAgentIdentity(
|
||||
agentDefinition.GetAgentId(),
|
||||
agentDefinition.GetAgentName());
|
||||
|
||||
switch (sessionEvent)
|
||||
{
|
||||
case ProviderAssistantMessageDeltaEvent messageDelta:
|
||||
RecordObservedAgentForMessage(agent, messageDelta.MessageId);
|
||||
QueueThinkingIfNeeded(agent);
|
||||
break;
|
||||
case ProviderAssistantMessageEvent assistantMessage:
|
||||
RecordObservedAgentForMessage(agent, assistantMessage.MessageId);
|
||||
QueueThinkingIfNeeded(agent);
|
||||
if (!string.IsNullOrWhiteSpace(assistantMessage.Content)
|
||||
&& TryFinalizeTranscriptMessage(
|
||||
assistantMessage.MessageId,
|
||||
agent.AgentName,
|
||||
assistantMessage.Content,
|
||||
out TranscriptSegment finalizedSegment))
|
||||
{
|
||||
_pendingEvents.Enqueue(CreateTurnDeltaEvent(
|
||||
finalizedSegment.MessageId,
|
||||
finalizedSegment.AuthorName,
|
||||
string.Empty,
|
||||
finalizedSegment.Content));
|
||||
}
|
||||
|
||||
if (assistantMessage.HasToolRequests)
|
||||
{
|
||||
QueueMessageReclassifiedIfNeeded(assistantMessage.MessageId);
|
||||
}
|
||||
break;
|
||||
case ProviderToolExecutionStartEvent toolExecutionStart:
|
||||
string toolCallId = toolExecutionStart.ToolCallId;
|
||||
string toolName = toolExecutionStart.ToolName;
|
||||
bool shouldQueueToolActivity = TrackToolCall(toolCallId, toolName, toolExecutionStart.ToolArguments);
|
||||
ActiveAgent = agent;
|
||||
if (shouldQueueToolActivity)
|
||||
{
|
||||
AgentActivityEventDto? toolActivity = CreateToolCallingActivity(
|
||||
agent, toolName, toolCallId, toolExecutionStart.ToolArguments);
|
||||
if (toolActivity is not null)
|
||||
{
|
||||
_pendingEvents.Enqueue(toolActivity);
|
||||
}
|
||||
}
|
||||
|
||||
QueueMessageReclassifiedIfNeeded(_lastObservedMessageId);
|
||||
break;
|
||||
case ProviderToolExecutionProgressEvent toolExecutionProgress:
|
||||
ActiveAgent = agent;
|
||||
TrackToolExecutionProgress(toolExecutionProgress.ToolCallId, toolExecutionProgress.ProgressMessage);
|
||||
break;
|
||||
case ProviderToolExecutionPartialResultEvent toolExecutionPartialResult:
|
||||
ActiveAgent = agent;
|
||||
TrackToolExecutionPartialResult(toolExecutionPartialResult.ToolCallId, toolExecutionPartialResult.PartialOutput);
|
||||
break;
|
||||
case ProviderToolExecutionCompleteEvent toolExecutionComplete:
|
||||
ActiveAgent = agent;
|
||||
TrackToolExecutionComplete(toolExecutionComplete);
|
||||
break;
|
||||
case ProviderAssistantIntentEvent intentEvent:
|
||||
ActiveAgent = agent;
|
||||
QueueThinkingIfNeeded(agent);
|
||||
TrackLatestIntent(agent.AgentId, intentEvent.Intent);
|
||||
AssistantIntentEventDto? assistantIntent = CreateAssistantIntentEvent(agent, intentEvent.Intent);
|
||||
if (assistantIntent is not null)
|
||||
{
|
||||
_pendingEvents.Enqueue(assistantIntent);
|
||||
}
|
||||
break;
|
||||
case ProviderAssistantReasoningDeltaEvent reasoningDelta:
|
||||
ActiveAgent = agent;
|
||||
QueueThinkingIfNeeded(agent);
|
||||
TrackReasoningContent(reasoningDelta.ReasoningId, reasoningDelta.DeltaContent, isComplete: false);
|
||||
ReasoningDeltaEventDto? reasoningDeltaEvent = CreateReasoningDeltaEvent(
|
||||
agent,
|
||||
reasoningDelta.ReasoningId,
|
||||
reasoningDelta.DeltaContent);
|
||||
if (reasoningDeltaEvent is not null)
|
||||
{
|
||||
_pendingEvents.Enqueue(reasoningDeltaEvent);
|
||||
}
|
||||
break;
|
||||
case ProviderAssistantReasoningEvent reasoning:
|
||||
ActiveAgent = agent;
|
||||
TrackReasoningContent(reasoning.ReasoningId, reasoning.Content, isComplete: true);
|
||||
break;
|
||||
case ProviderAssistantTurnStartEvent turnStart:
|
||||
ActiveAgent = agent;
|
||||
CurrentProviderTurnId = turnStart.TurnId;
|
||||
break;
|
||||
case ProviderAssistantTurnEndEvent turnEnd:
|
||||
ActiveAgent = agent;
|
||||
LatestCompletedProviderTurnId = turnEnd.TurnId;
|
||||
if (string.Equals(CurrentProviderTurnId, turnEnd.TurnId, StringComparison.Ordinal))
|
||||
{
|
||||
CurrentProviderTurnId = null;
|
||||
}
|
||||
break;
|
||||
case ProviderSubagentStartedEvent started:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateSubagentStartedEvent(agent, started));
|
||||
break;
|
||||
case ProviderSubagentCompletedEvent completed:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateSubagentCompletedEvent(agent, completed));
|
||||
break;
|
||||
case ProviderSubagentFailedEvent failed:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateSubagentFailedEvent(agent, failed));
|
||||
break;
|
||||
case ProviderSubagentSelectedEvent selected:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateSubagentSelectedEvent(agent, selected));
|
||||
break;
|
||||
case ProviderSubagentDeselectedEvent:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateSubagentDeselectedEvent(agent));
|
||||
break;
|
||||
case ProviderSkillInvokedEvent skillInvoked:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateSkillInvokedEvent(agent, skillInvoked));
|
||||
break;
|
||||
case ProviderHookStartEvent hookStart:
|
||||
ActiveAgent = agent;
|
||||
if (!SuppressHookLifecycleEvents)
|
||||
{
|
||||
_pendingEvents.Enqueue(CreateHookLifecycleEvent(
|
||||
agent,
|
||||
"start",
|
||||
hookStart.HookInvocationId,
|
||||
hookStart.HookType,
|
||||
input: hookStart.Input));
|
||||
}
|
||||
break;
|
||||
case ProviderHookEndEvent hookEnd:
|
||||
ActiveAgent = agent;
|
||||
if (!SuppressHookLifecycleEvents)
|
||||
{
|
||||
_pendingEvents.Enqueue(CreateHookLifecycleEvent(
|
||||
agent,
|
||||
"end",
|
||||
hookEnd.HookInvocationId,
|
||||
hookEnd.HookType,
|
||||
success: hookEnd.Success,
|
||||
output: hookEnd.Output,
|
||||
error: hookEnd.Error));
|
||||
}
|
||||
break;
|
||||
case ProviderAssistantUsageEvent assistantUsage:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateAssistantUsageEvent(agent, assistantUsage));
|
||||
break;
|
||||
case ProviderSessionUsageEvent usageInfo:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateUsageEvent(agent, usageInfo));
|
||||
break;
|
||||
case ProviderSessionCompactionStartEvent compactionStart:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateCompactionStartEvent(agent, compactionStart));
|
||||
break;
|
||||
case ProviderSessionCompactionCompleteEvent compactionComplete:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateCompactionCompleteEvent(agent, compactionComplete));
|
||||
break;
|
||||
case ProviderPendingMessagesModifiedEvent:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreatePendingMessagesModifiedEvent(agent));
|
||||
break;
|
||||
case ProviderMcpOauthRequiredEvent:
|
||||
ActiveAgent = agent;
|
||||
break;
|
||||
case ProviderExitPlanModeRequestedEvent:
|
||||
HasPendingExitPlanModeRequest = true;
|
||||
ActiveAgent = agent;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<SidecarEventDto> DrainPendingEvents()
|
||||
{
|
||||
List<SidecarEventDto> pending = [];
|
||||
while (_pendingEvents.TryDequeue(out SidecarEventDto? pendingEvent))
|
||||
{
|
||||
pending.Add(pendingEvent);
|
||||
}
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
public void EnqueuePendingMcpOauthRequest(McpOauthRequiredEventDto request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
_pendingMcpOauthRequests.Enqueue(request);
|
||||
}
|
||||
|
||||
public IReadOnlyList<McpOauthRequiredEventDto> DrainPendingMcpOauthRequests()
|
||||
{
|
||||
List<McpOauthRequiredEventDto> pending = [];
|
||||
while (_pendingMcpOauthRequests.TryDequeue(out McpOauthRequiredEventDto? request))
|
||||
{
|
||||
pending.Add(request);
|
||||
}
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
public bool TryGetToolExecution(string? toolCallId, [NotNullWhen(true)] out ProviderToolExecutionSnapshot? snapshot)
|
||||
{
|
||||
return ToolCalls.TryGetExecution(toolCallId, out snapshot);
|
||||
}
|
||||
|
||||
public bool TryGetReasoning(string? reasoningId, [NotNullWhen(true)] out ProviderReasoningSnapshot? snapshot)
|
||||
{
|
||||
snapshot = null;
|
||||
return !string.IsNullOrWhiteSpace(reasoningId)
|
||||
&& _reasoningById.TryGetValue(reasoningId, out snapshot);
|
||||
}
|
||||
|
||||
public bool TryGetLatestIntent(string? agentId, [NotNullWhen(true)] out string? intent)
|
||||
{
|
||||
intent = null;
|
||||
return !string.IsNullOrWhiteSpace(agentId)
|
||||
&& _latestIntentByAgentId.TryGetValue(agentId, out intent);
|
||||
}
|
||||
|
||||
public bool TryResolveObservedAgentForMessage(string? messageId, out AgentIdentity agent)
|
||||
{
|
||||
agent = default;
|
||||
return !string.IsNullOrWhiteSpace(messageId)
|
||||
&& _observedAgentsByMessageId.TryGetValue(messageId, out agent);
|
||||
}
|
||||
|
||||
public string CreateMessageId(string? messageId)
|
||||
{
|
||||
return messageId ?? $"{_command.RequestId}-delta-{_fallbackMessageIndex++}";
|
||||
}
|
||||
|
||||
public TranscriptSegment AppendDelta(
|
||||
string messageId,
|
||||
string authorName,
|
||||
string delta)
|
||||
{
|
||||
return _transcriptBuffer.AppendDelta(messageId, authorName, delta);
|
||||
}
|
||||
|
||||
public bool TryAppendDelta(
|
||||
string messageId,
|
||||
string authorName,
|
||||
string delta,
|
||||
out TranscriptSegment segment)
|
||||
{
|
||||
return _transcriptBuffer.TryAppendDelta(messageId, authorName, delta, out segment);
|
||||
}
|
||||
|
||||
public bool TryFinalizeTranscriptMessage(
|
||||
string messageId,
|
||||
string authorName,
|
||||
string content,
|
||||
out TranscriptSegment segment)
|
||||
{
|
||||
return _transcriptBuffer.TryApplySnapshot(messageId, authorName, content, out segment);
|
||||
}
|
||||
|
||||
public void ClearActiveAgentIfMatching(AgentIdentity completedAgent)
|
||||
{
|
||||
if (ActiveAgent.HasValue
|
||||
&& string.Equals(ActiveAgent.Value.AgentId, completedAgent.AgentId, StringComparison.Ordinal))
|
||||
{
|
||||
ActiveAgent = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordObservedAgentForMessage(AgentIdentity agent, string messageId)
|
||||
{
|
||||
ActiveAgent = agent;
|
||||
_observedAgentsByMessageId[messageId] = agent;
|
||||
_lastObservedMessageId = messageId;
|
||||
}
|
||||
|
||||
private bool TrackToolCall(
|
||||
string toolCallId,
|
||||
string toolName,
|
||||
IReadOnlyDictionary<string, object?>? toolArguments)
|
||||
{
|
||||
return ToolCalls.TryRecordToolRequest(toolCallId, toolName, toolArguments);
|
||||
}
|
||||
|
||||
private void TrackToolExecutionProgress(string toolCallId, string? progressMessage)
|
||||
{
|
||||
ToolCalls.RecordProgress(toolCallId, progressMessage);
|
||||
}
|
||||
|
||||
private void TrackToolExecutionPartialResult(string toolCallId, string? partialOutput)
|
||||
{
|
||||
ToolCalls.RecordPartialResult(toolCallId, partialOutput);
|
||||
}
|
||||
|
||||
private void TrackToolExecutionComplete(ProviderToolExecutionCompleteEvent toolExecution)
|
||||
{
|
||||
ToolCalls.RecordCompletion(toolExecution);
|
||||
}
|
||||
|
||||
private void TrackLatestIntent(string agentId, string? intent)
|
||||
{
|
||||
string? normalizedIntent = NormalizeOptionalString(intent);
|
||||
if (normalizedIntent is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_latestIntentByAgentId[agentId] = normalizedIntent;
|
||||
}
|
||||
|
||||
private void TrackReasoningContent(string? reasoningId, string? content, bool isComplete)
|
||||
{
|
||||
string? normalizedReasoningId = NormalizeOptionalString(reasoningId);
|
||||
if (normalizedReasoningId is null || content is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_reasoningById.AddOrUpdate(
|
||||
normalizedReasoningId,
|
||||
id => new ProviderReasoningSnapshot
|
||||
{
|
||||
ReasoningId = id,
|
||||
Content = content,
|
||||
IsComplete = isComplete,
|
||||
},
|
||||
(_, existing) => existing with
|
||||
{
|
||||
Content = isComplete ? content : string.Concat(existing.Content, content),
|
||||
IsComplete = isComplete || existing.IsComplete,
|
||||
});
|
||||
}
|
||||
|
||||
private void QueueMessageReclassifiedIfNeeded(string? messageId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(messageId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string normalizedMessageId = messageId.Trim();
|
||||
if (!_reclassifiedMessageIds.Add(normalizedMessageId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingEvents.Enqueue(CreateMessageReclassifiedEvent(normalizedMessageId));
|
||||
}
|
||||
|
||||
private AgentActivityEventDto? CreateThinkingActivityIfNeeded(AgentIdentity agent)
|
||||
{
|
||||
ActiveAgent = agent;
|
||||
|
||||
if (!_startedAgents.Add(agent.AgentId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AgentActivityEventDto
|
||||
{
|
||||
Type = "agent-activity",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
ActivityType = "thinking",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
SubworkflowNodeId = agent.Subworkflow?.SubworkflowNodeId,
|
||||
SubworkflowName = agent.Subworkflow?.SubworkflowName,
|
||||
};
|
||||
}
|
||||
|
||||
private AgentActivityEventDto? CreateToolCallingActivity(
|
||||
AgentIdentity agent,
|
||||
string toolName,
|
||||
string toolCallId,
|
||||
IReadOnlyDictionary<string, object?>? toolArguments = null)
|
||||
{
|
||||
if (toolName.StartsWith("handoff_to_", StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AgentActivityEventDto
|
||||
{
|
||||
Type = "agent-activity",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
ActivityType = "tool-calling",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
SubworkflowNodeId = agent.Subworkflow?.SubworkflowNodeId,
|
||||
SubworkflowName = agent.Subworkflow?.SubworkflowName,
|
||||
ToolName = toolName,
|
||||
ToolCallId = toolCallId,
|
||||
ToolArguments = toolArguments,
|
||||
};
|
||||
}
|
||||
|
||||
private AgentActivityEventDto CreateCompletedActivity(AgentIdentity agent)
|
||||
{
|
||||
return new AgentActivityEventDto
|
||||
{
|
||||
Type = "agent-activity",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
ActivityType = "completed",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
SubworkflowNodeId = agent.Subworkflow?.SubworkflowNodeId,
|
||||
SubworkflowName = agent.Subworkflow?.SubworkflowName,
|
||||
};
|
||||
}
|
||||
|
||||
private AgentActivityEventDto CreateSubworkflowActivity(
|
||||
string activityType,
|
||||
SubworkflowContext subworkflow)
|
||||
{
|
||||
return new AgentActivityEventDto
|
||||
{
|
||||
Type = "agent-activity",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
ActivityType = activityType,
|
||||
SubworkflowNodeId = subworkflow.SubworkflowNodeId,
|
||||
SubworkflowName = subworkflow.SubworkflowName,
|
||||
};
|
||||
}
|
||||
|
||||
private MessageReclassifiedEventDto CreateMessageReclassifiedEvent(string messageId)
|
||||
{
|
||||
return new MessageReclassifiedEventDto
|
||||
{
|
||||
Type = "message-reclassified",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
MessageId = messageId,
|
||||
NewKind = "thinking",
|
||||
};
|
||||
}
|
||||
|
||||
public void UpdateCompletedMessages(
|
||||
IReadOnlyList<ChatMessage> allMessages,
|
||||
IReadOnlyList<ChatMessage> inputMessages,
|
||||
IProviderTranscriptProjector transcriptProjector)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(transcriptProjector);
|
||||
|
||||
List<ChatMessage> newMessages = transcriptProjector.SelectNewOutputMessages(allMessages, inputMessages);
|
||||
CompletedMessages = transcriptProjector.ProjectCompletedMessagesFromSegments(
|
||||
_command,
|
||||
newMessages,
|
||||
_transcriptBuffer.Snapshot(),
|
||||
ActiveAgent);
|
||||
}
|
||||
|
||||
public IReadOnlyList<ChatMessageDto> FinalizeCompletedMessages(IProviderTranscriptProjector transcriptProjector)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(transcriptProjector);
|
||||
|
||||
if (CompletedMessages.Count == 0 && _transcriptBuffer.Count > 0)
|
||||
{
|
||||
CompletedMessages = transcriptProjector.ProjectCompletedMessagesFromSegments(
|
||||
_command,
|
||||
[],
|
||||
_transcriptBuffer.Snapshot(),
|
||||
ActiveAgent);
|
||||
}
|
||||
|
||||
foreach (ChatMessageDto message in CompletedMessages)
|
||||
{
|
||||
if (_reclassifiedMessageIds.Contains(message.Id))
|
||||
{
|
||||
message.MessageKind = "thinking";
|
||||
}
|
||||
}
|
||||
|
||||
return CompletedMessages;
|
||||
}
|
||||
|
||||
private SubagentEventDto CreateSubagentStartedEvent(
|
||||
AgentIdentity agent,
|
||||
ProviderSubagentStartedEvent data)
|
||||
{
|
||||
return new SubagentEventDto
|
||||
{
|
||||
Type = "subagent-event",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
EventKind = "started",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
ToolCallId = data.ToolCallId,
|
||||
CustomAgentName = data.AgentName,
|
||||
CustomAgentDisplayName = data.AgentDisplayName,
|
||||
CustomAgentDescription = data.AgentDescription,
|
||||
};
|
||||
}
|
||||
|
||||
private SubagentEventDto CreateSubagentCompletedEvent(
|
||||
AgentIdentity agent,
|
||||
ProviderSubagentCompletedEvent data)
|
||||
{
|
||||
return new SubagentEventDto
|
||||
{
|
||||
Type = "subagent-event",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
EventKind = "completed",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
ToolCallId = data.ToolCallId,
|
||||
CustomAgentName = data.AgentName,
|
||||
CustomAgentDisplayName = data.AgentDisplayName,
|
||||
};
|
||||
}
|
||||
|
||||
private SubagentEventDto CreateSubagentFailedEvent(
|
||||
AgentIdentity agent,
|
||||
ProviderSubagentFailedEvent data)
|
||||
{
|
||||
return new SubagentEventDto
|
||||
{
|
||||
Type = "subagent-event",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
EventKind = "failed",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
ToolCallId = data.ToolCallId,
|
||||
CustomAgentName = data.AgentName,
|
||||
CustomAgentDisplayName = data.AgentDisplayName,
|
||||
Error = data.Error,
|
||||
};
|
||||
}
|
||||
|
||||
private SubagentEventDto CreateSubagentSelectedEvent(
|
||||
AgentIdentity agent,
|
||||
ProviderSubagentSelectedEvent data)
|
||||
{
|
||||
return new SubagentEventDto
|
||||
{
|
||||
Type = "subagent-event",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
EventKind = "selected",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
CustomAgentName = data.AgentName,
|
||||
CustomAgentDisplayName = data.AgentDisplayName,
|
||||
Tools = data.Tools,
|
||||
};
|
||||
}
|
||||
|
||||
private SubagentEventDto CreateSubagentDeselectedEvent(AgentIdentity agent)
|
||||
{
|
||||
return new SubagentEventDto
|
||||
{
|
||||
Type = "subagent-event",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
EventKind = "deselected",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
};
|
||||
}
|
||||
|
||||
private AssistantIntentEventDto? CreateAssistantIntentEvent(
|
||||
AgentIdentity agent,
|
||||
string? intent)
|
||||
{
|
||||
string? normalizedIntent = intent?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalizedIntent))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AssistantIntentEventDto
|
||||
{
|
||||
Type = "assistant-intent",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
Intent = normalizedIntent,
|
||||
};
|
||||
}
|
||||
|
||||
private ReasoningDeltaEventDto? CreateReasoningDeltaEvent(
|
||||
AgentIdentity agent,
|
||||
string? reasoningId,
|
||||
string? deltaContent)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reasoningId)
|
||||
|| string.IsNullOrEmpty(deltaContent))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ReasoningDeltaEventDto
|
||||
{
|
||||
Type = "reasoning-delta",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
ReasoningId = reasoningId,
|
||||
ContentDelta = deltaContent,
|
||||
};
|
||||
}
|
||||
|
||||
private TurnDeltaEventDto CreateTurnDeltaEvent(
|
||||
string messageId,
|
||||
string authorName,
|
||||
string contentDelta,
|
||||
string? content)
|
||||
{
|
||||
return new TurnDeltaEventDto
|
||||
{
|
||||
Type = "turn-delta",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
MessageId = messageId,
|
||||
AuthorName = authorName,
|
||||
ContentDelta = contentDelta,
|
||||
Content = content,
|
||||
};
|
||||
}
|
||||
|
||||
private SkillInvokedEventDto CreateSkillInvokedEvent(
|
||||
AgentIdentity agent,
|
||||
ProviderSkillInvokedEvent data)
|
||||
{
|
||||
return new SkillInvokedEventDto
|
||||
{
|
||||
Type = "skill-invoked",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
SkillName = data.SkillName,
|
||||
Path = data.Path,
|
||||
Content = data.Content,
|
||||
AllowedTools = data.AllowedTools,
|
||||
PluginName = data.PluginName,
|
||||
PluginVersion = data.PluginVersion,
|
||||
};
|
||||
}
|
||||
|
||||
private HookLifecycleEventDto CreateHookLifecycleEvent(
|
||||
AgentIdentity agent,
|
||||
string phase,
|
||||
string hookInvocationId,
|
||||
string hookType,
|
||||
object? input = null,
|
||||
bool? success = null,
|
||||
object? output = null,
|
||||
string? error = null)
|
||||
{
|
||||
return new HookLifecycleEventDto
|
||||
{
|
||||
Type = "hook-lifecycle",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
HookInvocationId = hookInvocationId,
|
||||
HookType = hookType,
|
||||
Phase = phase,
|
||||
Input = input,
|
||||
Success = success,
|
||||
Output = output,
|
||||
Error = error,
|
||||
};
|
||||
}
|
||||
|
||||
private AssistantUsageEventDto CreateAssistantUsageEvent(
|
||||
AgentIdentity agent,
|
||||
ProviderAssistantUsageEvent data)
|
||||
{
|
||||
return new AssistantUsageEventDto
|
||||
{
|
||||
Type = "assistant-usage",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
Model = data.Model,
|
||||
InputTokens = data.InputTokens,
|
||||
OutputTokens = data.OutputTokens,
|
||||
CacheReadTokens = data.CacheReadTokens,
|
||||
CacheWriteTokens = data.CacheWriteTokens,
|
||||
Cost = data.Cost,
|
||||
Duration = data.Duration,
|
||||
TotalNanoAiu = data.TotalNanoAiu,
|
||||
QuotaSnapshots = data.QuotaSnapshots,
|
||||
};
|
||||
}
|
||||
|
||||
private SessionUsageEventDto CreateUsageEvent(AgentIdentity agent, ProviderSessionUsageEvent data)
|
||||
{
|
||||
return new SessionUsageEventDto
|
||||
{
|
||||
Type = "session-usage",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
TokenLimit = data.TokenLimit,
|
||||
CurrentTokens = data.CurrentTokens,
|
||||
MessagesLength = data.MessagesLength,
|
||||
SystemTokens = data.SystemTokens,
|
||||
ConversationTokens = data.ConversationTokens,
|
||||
ToolDefinitionsTokens = data.ToolDefinitionsTokens,
|
||||
IsInitial = data.IsInitial,
|
||||
};
|
||||
}
|
||||
|
||||
private SessionCompactionEventDto CreateCompactionStartEvent(
|
||||
AgentIdentity agent,
|
||||
ProviderSessionCompactionStartEvent data)
|
||||
{
|
||||
return new SessionCompactionEventDto
|
||||
{
|
||||
Type = "session-compaction",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
Phase = "start",
|
||||
SystemTokens = data.SystemTokens,
|
||||
ConversationTokens = data.ConversationTokens,
|
||||
ToolDefinitionsTokens = data.ToolDefinitionsTokens,
|
||||
};
|
||||
}
|
||||
|
||||
private SessionCompactionEventDto CreateCompactionCompleteEvent(
|
||||
AgentIdentity agent,
|
||||
ProviderSessionCompactionCompleteEvent data)
|
||||
{
|
||||
return new SessionCompactionEventDto
|
||||
{
|
||||
Type = "session-compaction",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
Phase = "complete",
|
||||
Success = data.Success,
|
||||
Error = data.Error,
|
||||
SystemTokens = data.SystemTokens,
|
||||
ConversationTokens = data.ConversationTokens,
|
||||
ToolDefinitionsTokens = data.ToolDefinitionsTokens,
|
||||
PreCompactionTokens = data.PreCompactionTokens,
|
||||
PostCompactionTokens = data.PostCompactionTokens,
|
||||
PreCompactionMessagesLength = data.PreCompactionMessagesLength,
|
||||
MessagesRemoved = data.MessagesRemoved,
|
||||
TokensRemoved = data.TokensRemoved,
|
||||
SummaryContent = data.SummaryContent,
|
||||
CheckpointNumber = data.CheckpointNumber,
|
||||
CheckpointPath = data.CheckpointPath,
|
||||
};
|
||||
}
|
||||
|
||||
private PendingMessagesModifiedEventDto CreatePendingMessagesModifiedEvent(AgentIdentity agent)
|
||||
{
|
||||
return new PendingMessagesModifiedEventDto
|
||||
{
|
||||
Type = "pending-messages-modified",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
using System.Collections;
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal static class WorkflowConditionEvaluator
|
||||
{
|
||||
private static readonly HashSet<string> SupportedConditionTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"always",
|
||||
"message-type",
|
||||
"expression",
|
||||
"property",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> SupportedOperators = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"equals",
|
||||
"not-equals",
|
||||
"contains",
|
||||
"gt",
|
||||
"lt",
|
||||
"regex",
|
||||
};
|
||||
|
||||
private static readonly Regex ComparisonExpression = new(
|
||||
@"^(?<path>[A-Za-z_][A-Za-z0-9_\.]*)\s*(?<operator>==|!=|>|<|contains|matches)\s*(?<value>""(?:[^""\\]|\\.)*""|'(?:[^'\\]|\\.)*'|-?\d+(?:\.\d+)?|true|false)$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
|
||||
|
||||
internal static bool IsSupportedConditionType(string? type)
|
||||
=> !string.IsNullOrWhiteSpace(type) && SupportedConditionTypes.Contains(type);
|
||||
|
||||
internal static bool IsSupportedOperator(string? @operator)
|
||||
=> !string.IsNullOrWhiteSpace(@operator) && SupportedOperators.Contains(@operator);
|
||||
|
||||
internal static bool IsSupportedExpression(string? expression)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(expression))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string trimmed = expression.Trim();
|
||||
if (string.Equals(trimmed, "true", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(trimmed, "false", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
string? delimiter = trimmed.Contains("&&", StringComparison.Ordinal) ? "&&" : null;
|
||||
if (delimiter is null && trimmed.Contains("||", StringComparison.Ordinal))
|
||||
{
|
||||
delimiter = "||";
|
||||
}
|
||||
|
||||
if (delimiter is null)
|
||||
{
|
||||
return ComparisonExpression.IsMatch(trimmed);
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.Split(delimiter, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
|
||||
.All(segment => ComparisonExpression.IsMatch(segment));
|
||||
}
|
||||
|
||||
internal static Func<object?, bool>? Compile(WorkflowEdgeDto edge)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(edge);
|
||||
|
||||
Func<object?, bool>? baseCondition = edge.Condition is null
|
||||
? null
|
||||
: CompileCondition(edge.Condition);
|
||||
|
||||
if (edge.IsLoop != true)
|
||||
{
|
||||
return baseCondition;
|
||||
}
|
||||
|
||||
int maxIterations = edge.MaxIterations ?? 0;
|
||||
int successfulIterations = 0;
|
||||
return payload =>
|
||||
{
|
||||
if (successfulIterations >= maxIterations)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (baseCondition is not null && !baseCondition(payload))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
successfulIterations++;
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
internal static bool Evaluate(EdgeConditionDto condition, object? payload)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(condition);
|
||||
return CompileCondition(condition)?.Invoke(payload) ?? true;
|
||||
}
|
||||
|
||||
private static Func<object?, bool>? CompileCondition(EdgeConditionDto condition)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(condition.Type)
|
||||
|| string.Equals(condition.Type, "always", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.Equals(condition.Type, "message-type", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string expectedTypeName = condition.TypeName?.Trim() ?? string.Empty;
|
||||
return payload =>
|
||||
{
|
||||
if (payload is null || string.IsNullOrWhiteSpace(expectedTypeName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Type payloadType = payload.GetType();
|
||||
return string.Equals(payloadType.Name, expectedTypeName, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(payloadType.FullName, expectedTypeName, StringComparison.OrdinalIgnoreCase);
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(condition.Type, "property", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string combinator = string.Equals(condition.Combinator, "or", StringComparison.OrdinalIgnoreCase) ? "or" : "and";
|
||||
return payload =>
|
||||
{
|
||||
IReadOnlyList<bool> results = condition.Rules
|
||||
.Select(rule => EvaluateRule(rule, payload))
|
||||
.ToArray();
|
||||
|
||||
if (results.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(combinator, "or", StringComparison.OrdinalIgnoreCase)
|
||||
? results.Any(result => result)
|
||||
: results.All(result => result);
|
||||
};
|
||||
}
|
||||
|
||||
if (string.Equals(condition.Type, "expression", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return payload => EvaluateExpression(condition.Expression, payload);
|
||||
}
|
||||
|
||||
throw new NotSupportedException($"Condition type \"{condition.Type}\" is not supported.");
|
||||
}
|
||||
|
||||
private static bool EvaluateExpression(string? expression, object? payload)
|
||||
{
|
||||
string trimmed = expression?.Trim() ?? string.Empty;
|
||||
if (string.Equals(trimmed, "true", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.Equals(trimmed, "false", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (trimmed.Contains("&&", StringComparison.Ordinal))
|
||||
{
|
||||
return trimmed
|
||||
.Split("&&", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
|
||||
.All(segment => EvaluateExpression(segment, payload));
|
||||
}
|
||||
|
||||
if (trimmed.Contains("||", StringComparison.Ordinal))
|
||||
{
|
||||
return trimmed
|
||||
.Split("||", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
|
||||
.Any(segment => EvaluateExpression(segment, payload));
|
||||
}
|
||||
|
||||
Match match = ComparisonExpression.Match(trimmed);
|
||||
if (!match.Success)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string path = match.Groups["path"].Value;
|
||||
string @operator = match.Groups["operator"].Value switch
|
||||
{
|
||||
"==" => "equals",
|
||||
"!=" => "not-equals",
|
||||
">" => "gt",
|
||||
"<" => "lt",
|
||||
"matches" => "regex",
|
||||
_ => match.Groups["operator"].Value,
|
||||
};
|
||||
|
||||
string rawValue = match.Groups["value"].Value;
|
||||
string value = UnwrapLiteral(rawValue);
|
||||
return EvaluateRule(
|
||||
new WorkflowConditionRuleDto
|
||||
{
|
||||
PropertyPath = path,
|
||||
Operator = @operator,
|
||||
Value = value,
|
||||
},
|
||||
payload);
|
||||
}
|
||||
|
||||
private static bool EvaluateRule(WorkflowConditionRuleDto rule, object? payload)
|
||||
{
|
||||
if (payload is null || string.IsNullOrWhiteSpace(rule.PropertyPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryResolvePropertyPath(payload, rule.PropertyPath, out object? actualValue))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return rule.Operator switch
|
||||
{
|
||||
"equals" => AreEqual(actualValue, rule.Value),
|
||||
"not-equals" => !AreEqual(actualValue, rule.Value),
|
||||
"contains" => ContainsValue(actualValue, rule.Value),
|
||||
"gt" => CompareAsNumberOrString(actualValue, rule.Value) > 0,
|
||||
"lt" => CompareAsNumberOrString(actualValue, rule.Value) < 0,
|
||||
"regex" => Regex.IsMatch(CoerceToString(actualValue), rule.Value, RegexOptions.CultureInvariant),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryResolvePropertyPath(object payload, string propertyPath, out object? value)
|
||||
{
|
||||
object? current = payload;
|
||||
foreach (string segment in propertyPath.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (!TryResolvePropertySegment(current, segment, out current))
|
||||
{
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
value = current;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryResolvePropertySegment(object? current, string segment, out object? value)
|
||||
{
|
||||
value = null;
|
||||
if (current is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (current is JsonElement jsonElement)
|
||||
{
|
||||
if (jsonElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (JsonProperty jsonProperty in jsonElement.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(jsonProperty.Name, segment, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = jsonProperty.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (current is IDictionary dictionary)
|
||||
{
|
||||
foreach (DictionaryEntry entry in dictionary)
|
||||
{
|
||||
if (entry.Key is string key && string.Equals(key, segment, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = entry.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Type type = current.GetType();
|
||||
PropertyInfo? property = type.GetProperties(BindingFlags.Instance | BindingFlags.Public)
|
||||
.FirstOrDefault(candidate => string.Equals(candidate.Name, segment, StringComparison.OrdinalIgnoreCase));
|
||||
if (property is not null)
|
||||
{
|
||||
value = property.GetValue(current);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool AreEqual(object? actualValue, string expectedValue)
|
||||
{
|
||||
if (actualValue is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TryConvertToDecimal(actualValue, out decimal actualDecimal)
|
||||
&& decimal.TryParse(expectedValue, NumberStyles.Float, CultureInfo.InvariantCulture, out decimal expectedDecimal))
|
||||
{
|
||||
return actualDecimal == expectedDecimal;
|
||||
}
|
||||
|
||||
return string.Equals(CoerceToString(actualValue), expectedValue, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool ContainsValue(object? actualValue, string expectedValue)
|
||||
{
|
||||
if (actualValue is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (actualValue is string actualString)
|
||||
{
|
||||
return actualString.Contains(expectedValue, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (actualValue is IEnumerable enumerable)
|
||||
{
|
||||
foreach (object? item in enumerable)
|
||||
{
|
||||
if (string.Equals(CoerceToString(item), expectedValue, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return CoerceToString(actualValue).Contains(expectedValue, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static int CompareAsNumberOrString(object? actualValue, string expectedValue)
|
||||
{
|
||||
if (actualValue is null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (TryConvertToDecimal(actualValue, out decimal actualDecimal)
|
||||
&& decimal.TryParse(expectedValue, NumberStyles.Float, CultureInfo.InvariantCulture, out decimal expectedDecimal))
|
||||
{
|
||||
return actualDecimal.CompareTo(expectedDecimal);
|
||||
}
|
||||
|
||||
return string.Compare(CoerceToString(actualValue), expectedValue, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool TryConvertToDecimal(object? value, out decimal result)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case byte byteValue:
|
||||
result = byteValue;
|
||||
return true;
|
||||
case short shortValue:
|
||||
result = shortValue;
|
||||
return true;
|
||||
case int intValue:
|
||||
result = intValue;
|
||||
return true;
|
||||
case long longValue:
|
||||
result = longValue;
|
||||
return true;
|
||||
case float floatValue:
|
||||
result = (decimal)floatValue;
|
||||
return true;
|
||||
case double doubleValue:
|
||||
result = (decimal)doubleValue;
|
||||
return true;
|
||||
case decimal decimalValue:
|
||||
result = decimalValue;
|
||||
return true;
|
||||
case JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.Number && jsonElement.TryGetDecimal(out decimal jsonDecimal):
|
||||
result = jsonDecimal;
|
||||
return true;
|
||||
default:
|
||||
return decimal.TryParse(
|
||||
CoerceToString(value),
|
||||
NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture,
|
||||
out result);
|
||||
}
|
||||
}
|
||||
|
||||
private static string CoerceToString(object? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (value is JsonElement jsonElement)
|
||||
{
|
||||
return jsonElement.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => jsonElement.GetString() ?? string.Empty,
|
||||
JsonValueKind.True => bool.TrueString,
|
||||
JsonValueKind.False => bool.FalseString,
|
||||
_ => jsonElement.ToString(),
|
||||
};
|
||||
}
|
||||
|
||||
return Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty;
|
||||
}
|
||||
|
||||
private static string UnwrapLiteral(string rawValue)
|
||||
{
|
||||
if (rawValue.Length >= 2
|
||||
&& ((rawValue.StartsWith('"') && rawValue.EndsWith('"'))
|
||||
|| (rawValue.StartsWith('\'') && rawValue.EndsWith('\''))))
|
||||
{
|
||||
return rawValue[1..^1];
|
||||
}
|
||||
|
||||
return rawValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal static class WorkflowDefinitionExtensions
|
||||
{
|
||||
public static IReadOnlyList<WorkflowNodeDto> GetAgentNodes(this WorkflowDefinitionDto workflow)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflow);
|
||||
|
||||
return workflow.Graph.Nodes
|
||||
.Where(IsAgentNode)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static IReadOnlyList<WorkflowNodeDto> GetAllAgentNodes(
|
||||
this WorkflowDefinitionDto workflow,
|
||||
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflow);
|
||||
|
||||
Dictionary<string, WorkflowDefinitionDto> workflowLibraryMap = CreateWorkflowLibraryMap(workflowLibrary);
|
||||
return GetAllAgentNodes(workflow, workflowLibraryMap);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<WorkflowNodeDto> GetAllAgentNodes(
|
||||
this WorkflowDefinitionDto workflow,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto>? workflowLibrary)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflow);
|
||||
|
||||
List<WorkflowNodeDto> agentNodes = [];
|
||||
CollectAgentNodes(
|
||||
workflow,
|
||||
workflowLibrary ?? EmptyWorkflowLibrary,
|
||||
agentNodes,
|
||||
new HashSet<string>(StringComparer.Ordinal),
|
||||
new HashSet<WorkflowDefinitionDto>(ReferenceEqualityComparer.Instance));
|
||||
return agentNodes;
|
||||
}
|
||||
|
||||
public static bool IsAgentNode(this WorkflowNodeDto node)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(node);
|
||||
return string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static bool IsSubWorkflowNode(this WorkflowNodeDto node)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(node);
|
||||
return string.Equals(node.Kind, "sub-workflow", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static string GetAgentId(this WorkflowNodeDto node)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(node);
|
||||
return !string.IsNullOrWhiteSpace(node.Config.Id) ? node.Config.Id : node.Id;
|
||||
}
|
||||
|
||||
public static string GetAgentName(this WorkflowNodeDto node)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(node);
|
||||
return FirstNonBlank(node.Config.Name, node.Label, node.Id) ?? "agent";
|
||||
}
|
||||
|
||||
public static WorkflowDefinitionDto ResolveSubWorkflowDefinition(
|
||||
this WorkflowNodeDto node,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto>? workflowLibrary)
|
||||
{
|
||||
return node.TryResolveSubWorkflowDefinition(workflowLibrary)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Sub-workflow node \"{node.Id}\" references unknown workflow \"{node.Config.WorkflowId}\".");
|
||||
}
|
||||
|
||||
public static WorkflowDefinitionDto? TryResolveSubWorkflowDefinition(
|
||||
this WorkflowNodeDto node,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto>? workflowLibrary)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(node);
|
||||
|
||||
if (node.Config.InlineWorkflow is not null)
|
||||
{
|
||||
return node.Config.InlineWorkflow;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(node.Config.WorkflowId)
|
||||
&& workflowLibrary is not null
|
||||
&& workflowLibrary.TryGetValue(node.Config.WorkflowId, out WorkflowDefinitionDto? workflow))
|
||||
{
|
||||
return workflow;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static bool IsOrchestrationMode(this WorkflowDefinitionDto workflow, string mode)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflow);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(mode);
|
||||
|
||||
return string.Equals(workflow.Settings.OrchestrationMode, mode, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static WorkflowNodeDto? FindSubWorkflowNode(
|
||||
this WorkflowDefinitionDto workflow,
|
||||
string? nodeId,
|
||||
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflow);
|
||||
string? normalizedNodeId = NormalizeOptionalString(nodeId);
|
||||
if (normalizedNodeId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return FindSubWorkflowNode(
|
||||
workflow,
|
||||
normalizedNodeId,
|
||||
CreateWorkflowLibraryMap(workflowLibrary),
|
||||
new HashSet<string>(StringComparer.Ordinal),
|
||||
new HashSet<WorkflowDefinitionDto>(ReferenceEqualityComparer.Instance));
|
||||
}
|
||||
|
||||
internal static WorkflowNodeDto? FindSubWorkflowNode(
|
||||
this WorkflowDefinitionDto workflow,
|
||||
string? nodeId,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto>? workflowLibrary)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflow);
|
||||
string? normalizedNodeId = NormalizeOptionalString(nodeId);
|
||||
if (normalizedNodeId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return FindSubWorkflowNode(
|
||||
workflow,
|
||||
normalizedNodeId,
|
||||
workflowLibrary ?? EmptyWorkflowLibrary,
|
||||
new HashSet<string>(StringComparer.Ordinal),
|
||||
new HashSet<WorkflowDefinitionDto>(ReferenceEqualityComparer.Instance));
|
||||
}
|
||||
|
||||
internal static string GetSubworkflowDisplayName(
|
||||
this WorkflowNodeDto node,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto>? workflowLibrary)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(node);
|
||||
|
||||
WorkflowDefinitionDto? resolvedWorkflow = null;
|
||||
if (node.Config.InlineWorkflow is not null)
|
||||
{
|
||||
resolvedWorkflow = node.Config.InlineWorkflow;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(node.Config.WorkflowId)
|
||||
&& workflowLibrary is not null
|
||||
&& workflowLibrary.TryGetValue(node.Config.WorkflowId, out WorkflowDefinitionDto? workflow))
|
||||
{
|
||||
resolvedWorkflow = workflow;
|
||||
}
|
||||
|
||||
return FirstNonBlank(node.Label, resolvedWorkflow?.Name, node.Config.WorkflowId, node.Id) ?? "sub-workflow";
|
||||
}
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, WorkflowDefinitionDto> EmptyWorkflowLibrary =
|
||||
new Dictionary<string, WorkflowDefinitionDto>(StringComparer.Ordinal);
|
||||
|
||||
private static void CollectAgentNodes(
|
||||
WorkflowDefinitionDto workflowDefinition,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
|
||||
List<WorkflowNodeDto> agentNodes,
|
||||
ISet<string> visitedWorkflowIds,
|
||||
ISet<WorkflowDefinitionDto> visitedAnonymousWorkflows)
|
||||
{
|
||||
string? workflowId = NormalizeOptionalString(workflowDefinition.Id);
|
||||
if (workflowId is not null)
|
||||
{
|
||||
if (!visitedWorkflowIds.Add(workflowId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
else if (!visitedAnonymousWorkflows.Add(workflowDefinition))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (WorkflowNodeDto node in workflowDefinition.Graph.Nodes)
|
||||
{
|
||||
if (node.IsAgentNode())
|
||||
{
|
||||
agentNodes.Add(node);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.Equals(node.Kind, "sub-workflow", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
WorkflowDefinitionDto? subWorkflow = node.TryResolveSubWorkflowDefinition(workflowLibrary);
|
||||
if (subWorkflow is not null)
|
||||
{
|
||||
CollectAgentNodes(subWorkflow, workflowLibrary, agentNodes, visitedWorkflowIds, visitedAnonymousWorkflows);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static WorkflowNodeDto? FindSubWorkflowNode(
|
||||
WorkflowDefinitionDto workflowDefinition,
|
||||
string nodeId,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
|
||||
ISet<string> visitedWorkflowIds,
|
||||
ISet<WorkflowDefinitionDto> visitedAnonymousWorkflows)
|
||||
{
|
||||
string? workflowId = NormalizeOptionalString(workflowDefinition.Id);
|
||||
if (workflowId is not null)
|
||||
{
|
||||
if (!visitedWorkflowIds.Add(workflowId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
else if (!visitedAnonymousWorkflows.Add(workflowDefinition))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (WorkflowNodeDto node in workflowDefinition.Graph.Nodes)
|
||||
{
|
||||
if (!node.IsSubWorkflowNode())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(node.Id, nodeId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return node;
|
||||
}
|
||||
|
||||
WorkflowDefinitionDto? subWorkflow = node.TryResolveSubWorkflowDefinition(workflowLibrary);
|
||||
if (subWorkflow is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
WorkflowNodeDto? match = FindSubWorkflowNode(
|
||||
subWorkflow,
|
||||
nodeId,
|
||||
workflowLibrary,
|
||||
visitedWorkflowIds,
|
||||
visitedAnonymousWorkflows);
|
||||
if (match is not null)
|
||||
{
|
||||
return match;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static Dictionary<string, WorkflowDefinitionDto> CreateWorkflowLibraryMap(
|
||||
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary)
|
||||
{
|
||||
return workflowLibrary?
|
||||
.Where(candidate => !string.IsNullOrWhiteSpace(candidate.Id))
|
||||
.GroupBy(candidate => candidate.Id, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal)
|
||||
?? new Dictionary<string, WorkflowDefinitionDto>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static string? FirstNonBlank(params string?[] values)
|
||||
{
|
||||
foreach (string? value in values)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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,20 +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,
|
||||
};
|
||||
}
|
||||
@@ -34,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(
|
||||
@@ -51,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
|
||||
{
|
||||
@@ -72,37 +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)
|
||||
{
|
||||
@@ -121,7 +121,8 @@ internal static class WorkflowRequestInfoInterpreter
|
||||
}
|
||||
|
||||
agent = AgentIdentityResolver.ResolveAgentIdentity(
|
||||
pattern,
|
||||
command.Workflow,
|
||||
command.WorkflowLibrary,
|
||||
target.Id,
|
||||
target.Name);
|
||||
return !string.IsNullOrWhiteSpace(agent.AgentName);
|
||||
@@ -130,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;
|
||||
}
|
||||
|
||||
@@ -171,6 +178,7 @@ internal static class WorkflowRequestInfoInterpreter
|
||||
{
|
||||
toolName = CodeInterpreterToolName;
|
||||
toolCallId = NormalizeOptionalString(codeInterpreterToolCall.CallId);
|
||||
toolArguments = NormalizeCodeInterpreterToolArguments(codeInterpreterToolCall);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -178,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();
|
||||
@@ -193,15 +403,18 @@ internal static class WorkflowRequestInfoInterpreter
|
||||
|
||||
private static WorkflowRequestHandoffPayload? DeserializeHandoffPayload(object handoffValue)
|
||||
{
|
||||
string json = JsonSerializer.Serialize(handoffValue, handoffValue.GetType());
|
||||
return JsonSerializer.Deserialize<WorkflowRequestHandoffPayload>(json);
|
||||
string json = JsonSerializer.Serialize(handoffValue, handoffValue.GetType(), JsonOptions);
|
||||
return JsonSerializer.Deserialize<WorkflowRequestHandoffPayload>(json, JsonOptions);
|
||||
}
|
||||
|
||||
private abstract record RequestInterpretation;
|
||||
|
||||
private sealed record HandoffRequestInterpretation(AgentIdentity TargetAgent) : RequestInterpretation;
|
||||
|
||||
private sealed record ToolRequestInterpretation(string ToolName, string? ToolCallId) : RequestInterpretation;
|
||||
private sealed record ToolRequestInterpretation(
|
||||
string ToolName,
|
||||
string? ToolCallId,
|
||||
IReadOnlyDictionary<string, object?>? ToolArguments) : RequestInterpretation;
|
||||
|
||||
private sealed record UnknownRequestInterpretation : RequestInterpretation;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class WorkflowRunner
|
||||
{
|
||||
public Workflow BuildWorkflow(
|
||||
WorkflowDefinitionDto workflowDefinition,
|
||||
IReadOnlyList<AIAgent> agents,
|
||||
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflowDefinition);
|
||||
ArgumentNullException.ThrowIfNull(agents);
|
||||
|
||||
Dictionary<string, WorkflowDefinitionDto> workflowLibraryMap = workflowLibrary?
|
||||
.Where(candidate => !string.IsNullOrWhiteSpace(candidate.Id))
|
||||
.GroupBy(candidate => candidate.Id, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal)
|
||||
?? new Dictionary<string, WorkflowDefinitionDto>(StringComparer.Ordinal);
|
||||
|
||||
List<string> agentIds = ResolveAgentIds(workflowDefinition, workflowLibraryMap);
|
||||
Dictionary<string, AIAgent> agentMap = agentIds
|
||||
.Zip(agents, (agentId, agent) => (agentId, agent))
|
||||
.ToDictionary(pair => pair.agentId, pair => pair.agent, StringComparer.Ordinal);
|
||||
|
||||
return BuildWorkflow(workflowDefinition, agentMap, workflowLibraryMap);
|
||||
}
|
||||
|
||||
private Workflow BuildWorkflow(
|
||||
WorkflowDefinitionDto workflowDefinition,
|
||||
IReadOnlyDictionary<string, AIAgent> agentMap,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary)
|
||||
{
|
||||
WorkflowNodeDto startNode = workflowDefinition.Graph.Nodes.Single(node =>
|
||||
string.Equals(node.Kind, "start", StringComparison.OrdinalIgnoreCase));
|
||||
WorkflowNodeDto endNode = workflowDefinition.Graph.Nodes.Single(node =>
|
||||
string.Equals(node.Kind, "end", StringComparison.OrdinalIgnoreCase));
|
||||
WorkflowStateScopeCatalog stateCatalog = new(workflowDefinition.Settings.StateScopes);
|
||||
|
||||
Dictionary<string, WorkflowNodeRoute> routes = new(StringComparer.Ordinal);
|
||||
foreach (WorkflowNodeDto node in workflowDefinition.Graph.Nodes)
|
||||
{
|
||||
routes[node.Id] = CreateNodeRoute(node, agentMap, workflowLibrary, stateCatalog);
|
||||
}
|
||||
|
||||
WorkflowBuilder builder = new(routes[startNode.Id].Entry);
|
||||
|
||||
foreach (WorkflowNodeRoute route in routes.Values)
|
||||
{
|
||||
foreach ((ExecutorBinding source, ExecutorBinding target) in route.InternalEdges)
|
||||
{
|
||||
builder.AddEdge(source, target);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (WorkflowEdgeDto edge in workflowDefinition.Graph.Edges.Where(edge =>
|
||||
string.Equals(edge.Kind, "direct", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
Func<object?, bool>? condition = WorkflowConditionEvaluator.Compile(edge);
|
||||
ExecutorBinding source = routes[edge.Source].Exit;
|
||||
ExecutorBinding target = routes[edge.Target].Entry;
|
||||
if (condition is null)
|
||||
{
|
||||
builder.AddEdge(source, target);
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.AddEdge<object>(source, target, condition);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (IGrouping<string, WorkflowEdgeDto> fanOutGroup in workflowDefinition.Graph.Edges
|
||||
.Where(edge => string.Equals(edge.Kind, "fan-out", StringComparison.OrdinalIgnoreCase))
|
||||
.GroupBy(edge => edge.Source, StringComparer.Ordinal))
|
||||
{
|
||||
WorkflowEdgeDto[] fanOutEdges = fanOutGroup.ToArray();
|
||||
ExecutorBinding source = routes[fanOutGroup.Key].Exit;
|
||||
ExecutorBinding[] targets = fanOutEdges.Select(edge => routes[edge.Target].Entry).ToArray();
|
||||
Func<object?, bool>?[] compiledConditions = fanOutEdges
|
||||
.Select(WorkflowConditionEvaluator.Compile)
|
||||
.ToArray();
|
||||
bool hasConditionalRouting = fanOutEdges.Any(edge => edge.Condition is not null);
|
||||
if (!hasConditionalRouting)
|
||||
{
|
||||
builder.AddFanOutEdge(source, targets);
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.AddFanOutEdge<object>(
|
||||
source,
|
||||
targets,
|
||||
(payload, _) => fanOutEdges
|
||||
.Select((edge, index) => (edge, index))
|
||||
.Where(pair => compiledConditions[pair.index]?.Invoke(payload) ?? true)
|
||||
.Select(pair => pair.index)
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
foreach (IGrouping<string, WorkflowEdgeDto> fanInGroup in workflowDefinition.Graph.Edges
|
||||
.Where(edge => string.Equals(edge.Kind, "fan-in", StringComparison.OrdinalIgnoreCase))
|
||||
.GroupBy(edge => edge.Target, StringComparer.Ordinal))
|
||||
{
|
||||
builder.AddFanInBarrierEdge(
|
||||
fanInGroup.Select(edge => routes[edge.Source].Exit).ToArray(),
|
||||
routes[fanInGroup.Key].Entry);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(workflowDefinition.Name))
|
||||
{
|
||||
builder = builder.WithName(workflowDefinition.Name);
|
||||
}
|
||||
|
||||
return builder.WithOutputFrom(routes[endNode.Id].Exit).WithOpenTelemetry().Build();
|
||||
}
|
||||
|
||||
private WorkflowNodeRoute CreateNodeRoute(
|
||||
WorkflowNodeDto node,
|
||||
IReadOnlyDictionary<string, AIAgent> agentMap,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
|
||||
WorkflowStateScopeCatalog stateCatalog)
|
||||
{
|
||||
if (string.Equals(node.Kind, "start", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ExecutorBinding binding = new ChatForwardingExecutor(node.Id).BindExecutor();
|
||||
return new WorkflowNodeRoute(binding);
|
||||
}
|
||||
|
||||
if (string.Equals(node.Kind, "end", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ExecutorBinding binding = new WorkflowOutputMessagesExecutor(node.Id).BindExecutor();
|
||||
return new WorkflowNodeRoute(binding);
|
||||
}
|
||||
|
||||
if (string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string agentId = !string.IsNullOrWhiteSpace(node.Config.Id) ? node.Config.Id : node.Id;
|
||||
if (!agentMap.TryGetValue(agentId, out AIAgent? agent))
|
||||
{
|
||||
throw new InvalidOperationException($"Workflow node \"{node.Id}\" references unknown agent \"{agentId}\".");
|
||||
}
|
||||
|
||||
return new WorkflowNodeRoute(agent.BindAsExecutor(AgentHostOptionsFactory.CreateDefault()));
|
||||
}
|
||||
|
||||
if (string.Equals(node.Kind, "code-executor", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string implementation = NormalizeRequired(node.Config.Implementation, $"Workflow code executor \"{node.Id}\" requires an implementation.");
|
||||
ExecutorBinding binding = new WorkflowCodeExecutor(node.Id, implementation, stateCatalog).BindExecutor();
|
||||
return new WorkflowNodeRoute(binding);
|
||||
}
|
||||
|
||||
if (string.Equals(node.Kind, "function-executor", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string functionRef = NormalizeRequired(node.Config.FunctionRef, $"Workflow function executor \"{node.Id}\" requires a functionRef.");
|
||||
if (!WorkflowFunctionRegistry.IsSupported(functionRef))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Workflow function executor \"{node.Id}\" references unsupported functionRef \"{functionRef}\".");
|
||||
}
|
||||
|
||||
ExecutorBinding binding = new WorkflowFunctionExecutor(node.Id, functionRef, node.Config.Parameters, stateCatalog).BindExecutor();
|
||||
return new WorkflowNodeRoute(binding);
|
||||
}
|
||||
|
||||
if (string.Equals(node.Kind, "request-port", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return CreateRequestPortRoute(node);
|
||||
}
|
||||
|
||||
if (string.Equals(node.Kind, "sub-workflow", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
WorkflowDefinitionDto subWorkflowDefinition = node.ResolveSubWorkflowDefinition(workflowLibrary);
|
||||
Workflow subWorkflow = BuildWorkflow(subWorkflowDefinition, agentMap, workflowLibrary);
|
||||
return new WorkflowNodeRoute(subWorkflow.BindAsExecutor(node.Id));
|
||||
}
|
||||
|
||||
throw new NotSupportedException($"Workflow node kind \"{node.Kind}\" is not executable yet.");
|
||||
}
|
||||
|
||||
private static WorkflowNodeRoute CreateRequestPortRoute(WorkflowNodeDto node)
|
||||
{
|
||||
WorkflowRequestPortNodeDefinition definition = new(
|
||||
node.Id,
|
||||
NormalizeOptionalString(node.Label) ?? node.Id,
|
||||
NormalizeRequired(node.Config.PortId, $"Workflow request port \"{node.Id}\" requires a portId."),
|
||||
NormalizeRequired(node.Config.RequestType, $"Workflow request port \"{node.Id}\" requires a requestType."),
|
||||
NormalizeRequired(node.Config.ResponseType, $"Workflow request port \"{node.Id}\" requires a responseType."),
|
||||
NormalizeOptionalString(node.Config.Prompt));
|
||||
|
||||
RequestPort port = new(definition.PortId, typeof(WorkflowRequestPortPromptRequest), typeof(object));
|
||||
ExecutorBinding entry = new WorkflowRequestPortIngressExecutor(definition, port).BindExecutor();
|
||||
ExecutorBinding portBinding = new RequestPortBinding(port, false);
|
||||
ExecutorBinding exit = new WorkflowRequestPortResponseExecutor(node.Id).BindExecutor();
|
||||
return new WorkflowNodeRoute(entry, exit, [(entry, portBinding), (portBinding, exit)]);
|
||||
}
|
||||
|
||||
private static string NormalizeRequired(string? value, string errorMessage)
|
||||
=> NormalizeOptionalString(value) ?? throw new InvalidOperationException(errorMessage);
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static List<string> ResolveAgentIds(
|
||||
WorkflowDefinitionDto workflowDefinition,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary)
|
||||
{
|
||||
return workflowDefinition.GetAllAgentNodes(workflowLibrary)
|
||||
.Select(node => node.GetAgentId())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private sealed record WorkflowNodeRoute(
|
||||
ExecutorBinding Entry,
|
||||
ExecutorBinding Exit,
|
||||
IReadOnlyList<(ExecutorBinding Source, ExecutorBinding Target)> InternalEdges)
|
||||
{
|
||||
public WorkflowNodeRoute(ExecutorBinding binding)
|
||||
: this(binding, binding, [])
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
using System.Linq;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
public sealed class WorkflowValidator
|
||||
{
|
||||
private static readonly HashSet<string> ExecutableNodeKinds = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"start",
|
||||
"end",
|
||||
"agent",
|
||||
"code-executor",
|
||||
"function-executor",
|
||||
"sub-workflow",
|
||||
"request-port",
|
||||
};
|
||||
|
||||
public IReadOnlyList<WorkflowValidationIssueDto> Validate(
|
||||
WorkflowDefinitionDto workflow,
|
||||
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
|
||||
{
|
||||
List<WorkflowValidationIssueDto> issues = [];
|
||||
Dictionary<string, WorkflowDefinitionDto>? workflowLibraryById = workflowLibrary?
|
||||
.Where(candidate => !string.IsNullOrWhiteSpace(candidate.Id))
|
||||
.GroupBy(candidate => candidate.Id, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(workflow.Name))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "name",
|
||||
Message = "Workflow name is required.",
|
||||
});
|
||||
}
|
||||
|
||||
if (workflow.Graph.Nodes.Count == 0)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph",
|
||||
Message = "Workflow graph must include nodes.",
|
||||
});
|
||||
return issues;
|
||||
}
|
||||
|
||||
Dictionary<string, WorkflowNodeDto> nodesById = new(StringComparer.Ordinal);
|
||||
HashSet<string> edgeIds = new(StringComparer.Ordinal);
|
||||
Dictionary<string, int> incomingCounts = new(StringComparer.Ordinal);
|
||||
Dictionary<string, int> outgoingCounts = new(StringComparer.Ordinal);
|
||||
|
||||
foreach (WorkflowNodeDto node in workflow.Graph.Nodes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(node.Id))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes.id",
|
||||
Message = "Workflow nodes must have an ID.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!nodesById.TryAdd(node.Id, node))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes.id",
|
||||
NodeId = node.Id,
|
||||
Message = $"Workflow graph contains duplicate node \"{node.Id}\".",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ExecutableNodeKinds.Contains(node.Kind))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes.kind",
|
||||
NodeId = node.Id,
|
||||
Message = $"Workflow node kind \"{node.Kind}\" is not executable yet.",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(node.Config.Name))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes.config.name",
|
||||
NodeId = node.Id,
|
||||
Message = "Agent nodes require a name.",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(node.Config.Model))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes.config.model",
|
||||
NodeId = node.Id,
|
||||
Message = $"Agent node \"{node.Label}\" requires a model.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ValidateExecutableNode(node, issues);
|
||||
ValidateSubWorkflowNode(node, workflowLibraryById, issues);
|
||||
}
|
||||
|
||||
foreach (WorkflowEdgeDto edge in workflow.Graph.Edges)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(edge.Id))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.id",
|
||||
Message = "Workflow edges must have an ID.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!edgeIds.Add(edge.Id))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.id",
|
||||
EdgeId = edge.Id,
|
||||
Message = $"Workflow graph contains duplicate edge \"{edge.Id}\".",
|
||||
});
|
||||
}
|
||||
|
||||
if (!nodesById.ContainsKey(edge.Source) || !nodesById.ContainsKey(edge.Target))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges",
|
||||
EdgeId = edge.Id,
|
||||
Message = $"Workflow edge \"{edge.Id}\" must connect known nodes.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
outgoingCounts[edge.Source] = outgoingCounts.TryGetValue(edge.Source, out int outgoing)
|
||||
? outgoing + 1
|
||||
: 1;
|
||||
incomingCounts[edge.Target] = incomingCounts.TryGetValue(edge.Target, out int incoming)
|
||||
? incoming + 1
|
||||
: 1;
|
||||
|
||||
ValidateEdgeCondition(edge, issues);
|
||||
}
|
||||
|
||||
List<WorkflowNodeDto> startNodes = workflow.Graph.Nodes
|
||||
.Where(node => string.Equals(node.Kind, "start", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
List<WorkflowNodeDto> endNodes = workflow.Graph.Nodes
|
||||
.Where(node => string.Equals(node.Kind, "end", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
List<WorkflowNodeDto> executableWorkNodes = workflow.Graph.Nodes
|
||||
.Where(node =>
|
||||
string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(node.Kind, "code-executor", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(node.Kind, "function-executor", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(node.Kind, "request-port", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(node.Kind, "sub-workflow", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (startNodes.Count != 1)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes",
|
||||
Message = "Workflow graphs must contain exactly one start node.",
|
||||
});
|
||||
}
|
||||
|
||||
if (endNodes.Count != 1)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes",
|
||||
Message = "Workflow graphs must contain exactly one end node.",
|
||||
});
|
||||
}
|
||||
|
||||
if (executableWorkNodes.Count == 0)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes",
|
||||
Message = "Workflow graphs must contain at least one executable work node.",
|
||||
});
|
||||
}
|
||||
|
||||
foreach (WorkflowNodeDto startNode in startNodes)
|
||||
{
|
||||
if (incomingCounts.GetValueOrDefault(startNode.Id) != 0)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges",
|
||||
NodeId = startNode.Id,
|
||||
Message = "Start nodes cannot have incoming edges.",
|
||||
});
|
||||
}
|
||||
|
||||
if (outgoingCounts.GetValueOrDefault(startNode.Id) == 0)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges",
|
||||
NodeId = startNode.Id,
|
||||
Message = "Start nodes must connect to at least one downstream node.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
foreach (WorkflowNodeDto endNode in endNodes)
|
||||
{
|
||||
if (outgoingCounts.GetValueOrDefault(endNode.Id) != 0)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges",
|
||||
NodeId = endNode.Id,
|
||||
Message = "End nodes cannot have outgoing edges.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
foreach (IGrouping<string, WorkflowEdgeDto> fanOutGroup in workflow.Graph.Edges
|
||||
.Where(edge => string.Equals(edge.Kind, "fan-out", StringComparison.OrdinalIgnoreCase))
|
||||
.GroupBy(edge => edge.Source, StringComparer.Ordinal))
|
||||
{
|
||||
if (fanOutGroup.Count() < 2)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.kind",
|
||||
NodeId = fanOutGroup.Key,
|
||||
Message = "Fan-out edges require at least two outgoing fan-out connections from the same source.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
foreach (IGrouping<string, WorkflowEdgeDto> fanInGroup in workflow.Graph.Edges
|
||||
.Where(edge => string.Equals(edge.Kind, "fan-in", StringComparison.OrdinalIgnoreCase))
|
||||
.GroupBy(edge => edge.Target, StringComparer.Ordinal))
|
||||
{
|
||||
if (fanInGroup.Count() < 2)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.kind",
|
||||
NodeId = fanInGroup.Key,
|
||||
Message = "Fan-in edges require at least two incoming fan-in connections to the same target.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
WorkflowNodeDto? start = startNodes.FirstOrDefault();
|
||||
if (start is not null && endNodes.Count > 0 && !HasPathToAnyEnd(start.Id, workflow.Graph, endNodes.Select(node => node.Id)))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges",
|
||||
Message = "Workflow graph must include a path from the start node to at least one end node.",
|
||||
});
|
||||
}
|
||||
|
||||
if (workflow.Settings.MaxIterations is int workflowMaxIterations
|
||||
&& (workflowMaxIterations < 1 || workflowMaxIterations > 100))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "settings.maxIterations",
|
||||
Message = "Workflow maxIterations must be between 1 and 100.",
|
||||
});
|
||||
}
|
||||
|
||||
foreach (WorkflowEdgeDto edge in workflow.Graph.Edges)
|
||||
{
|
||||
bool participatesInCycle = IsLoopEdge(workflow.Graph, edge);
|
||||
if (!participatesInCycle)
|
||||
{
|
||||
if (edge.IsLoop == true)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Level = "warning",
|
||||
Field = "graph.edges.isLoop",
|
||||
EdgeId = edge.Id,
|
||||
Message = "This edge is marked as a loop but does not currently form a cycle.",
|
||||
});
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.Equals(edge.Kind, "direct", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.kind",
|
||||
EdgeId = edge.Id,
|
||||
Message = "Loop edges currently support only direct edges.",
|
||||
});
|
||||
}
|
||||
|
||||
if (edge.IsLoop != true)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.isLoop",
|
||||
EdgeId = edge.Id,
|
||||
Message = "Edges that participate in a cycle must be explicitly marked as loops.",
|
||||
});
|
||||
}
|
||||
|
||||
if ((edge.Condition is null || string.Equals(edge.Condition.Type, "always", StringComparison.OrdinalIgnoreCase))
|
||||
&& (edge.MaxIterations is null || edge.MaxIterations < 1))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.condition",
|
||||
EdgeId = edge.Id,
|
||||
Message = "Loop edges require either a non-default condition or a maxIterations cap so the loop can terminate.",
|
||||
});
|
||||
}
|
||||
|
||||
if (edge.MaxIterations is null || edge.MaxIterations < 1)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.maxIterations",
|
||||
EdgeId = edge.Id,
|
||||
Message = "Loop edges require a maxIterations value of at least 1.",
|
||||
});
|
||||
}
|
||||
|
||||
HashSet<string> componentNodes = CollectStronglyConnectedNodes(workflow.Graph, edge.Source);
|
||||
bool hasExitPath = workflow.Graph.Edges.Any(candidate =>
|
||||
componentNodes.Contains(candidate.Source) && !componentNodes.Contains(candidate.Target));
|
||||
if (!hasExitPath)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges",
|
||||
EdgeId = edge.Id,
|
||||
Message = "Loop cycles must include an exit path to a node outside the loop.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
private void ValidateSubWorkflowNode(
|
||||
WorkflowNodeDto node,
|
||||
IReadOnlyDictionary<string, WorkflowDefinitionDto>? workflowLibraryById,
|
||||
List<WorkflowValidationIssueDto> issues)
|
||||
{
|
||||
if (!string.Equals(node.Kind, "sub-workflow", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool hasWorkflowId = !string.IsNullOrWhiteSpace(node.Config.WorkflowId);
|
||||
bool hasInlineWorkflow = node.Config.InlineWorkflow is not null;
|
||||
if (hasWorkflowId == hasInlineWorkflow)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes.config",
|
||||
NodeId = node.Id,
|
||||
Message = "Sub-workflow nodes must specify exactly one of workflowId or inlineWorkflow.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasWorkflowId
|
||||
&& workflowLibraryById is not null
|
||||
&& !workflowLibraryById.ContainsKey(node.Config.WorkflowId!))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes.config.workflowId",
|
||||
NodeId = node.Id,
|
||||
Message = $"Sub-workflow node \"{node.Label}\" references unknown workflow \"{node.Config.WorkflowId}\".",
|
||||
});
|
||||
}
|
||||
|
||||
if (node.Config.InlineWorkflow is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (WorkflowValidationIssueDto inlineIssue in Validate(node.Config.InlineWorkflow, workflowLibraryById?.Values.ToList()))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Level = inlineIssue.Level,
|
||||
Field = inlineIssue.Field is null
|
||||
? "graph.nodes.config.inlineWorkflow"
|
||||
: $"graph.nodes.config.inlineWorkflow.{inlineIssue.Field}",
|
||||
NodeId = node.Id,
|
||||
EdgeId = inlineIssue.EdgeId,
|
||||
Message = $"Inline workflow for node \"{node.Label}\": {inlineIssue.Message}",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateExecutableNode(
|
||||
WorkflowNodeDto node,
|
||||
List<WorkflowValidationIssueDto> issues)
|
||||
{
|
||||
if (string.Equals(node.Kind, "code-executor", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(node.Config.Implementation))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes.config.implementation",
|
||||
NodeId = node.Id,
|
||||
Message = "Code executor nodes require a non-empty implementation.",
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(node.Kind, "function-executor", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(node.Config.FunctionRef))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes.config.functionRef",
|
||||
NodeId = node.Id,
|
||||
Message = "Function executor nodes require a non-empty functionRef.",
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(node.Kind, "request-port", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(node.Config.PortId))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes.config.portId",
|
||||
NodeId = node.Id,
|
||||
Message = "Request port nodes require a non-empty portId.",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(node.Config.RequestType))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes.config.requestType",
|
||||
NodeId = node.Id,
|
||||
Message = "Request port nodes require a non-empty requestType.",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(node.Config.ResponseType))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.nodes.config.responseType",
|
||||
NodeId = node.Id,
|
||||
Message = "Request port nodes require a non-empty responseType.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateEdgeCondition(WorkflowEdgeDto edge, List<WorkflowValidationIssueDto> issues)
|
||||
{
|
||||
if (edge.Condition is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(edge.Kind, "fan-in", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.condition",
|
||||
EdgeId = edge.Id,
|
||||
Message = "Fan-in edges do not support conditions.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!WorkflowConditionEvaluator.IsSupportedConditionType(edge.Condition.Type))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.condition.type",
|
||||
EdgeId = edge.Id,
|
||||
Message = $"Condition type \"{edge.Condition.Type}\" is not supported.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(edge.Condition.Type, "message-type", StringComparison.OrdinalIgnoreCase)
|
||||
&& string.IsNullOrWhiteSpace(edge.Condition.TypeName))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.condition.typeName",
|
||||
EdgeId = edge.Id,
|
||||
Message = "Message-type conditions require a type name.",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.Equals(edge.Condition.Type, "expression", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(edge.Condition.Expression))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.condition.expression",
|
||||
EdgeId = edge.Id,
|
||||
Message = "Expression conditions require a non-empty expression.",
|
||||
});
|
||||
}
|
||||
else if (!WorkflowConditionEvaluator.IsSupportedExpression(edge.Condition.Expression))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.condition.expression",
|
||||
EdgeId = edge.Id,
|
||||
Message = "Expression conditions currently support simple comparisons using ==, !=, >, <, contains, matches, optionally combined with && or ||.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (string.Equals(edge.Condition.Type, "property", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (edge.Condition.Rules.Count == 0)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.condition.rules",
|
||||
EdgeId = edge.Id,
|
||||
Message = "Property conditions require at least one rule.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(edge.Condition.Combinator)
|
||||
&& !string.Equals(edge.Condition.Combinator, "and", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(edge.Condition.Combinator, "or", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.condition.combinator",
|
||||
EdgeId = edge.Id,
|
||||
Message = "Property conditions must use the \"and\" or \"or\" combinator.",
|
||||
});
|
||||
}
|
||||
|
||||
foreach (WorkflowConditionRuleDto rule in edge.Condition.Rules)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rule.PropertyPath))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.condition.rules.propertyPath",
|
||||
EdgeId = edge.Id,
|
||||
Message = "Property condition rules require a property path.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!WorkflowConditionEvaluator.IsSupportedOperator(rule.Operator))
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.condition.rules.operator",
|
||||
EdgeId = edge.Id,
|
||||
Message = $"Property condition operator \"{rule.Operator}\" is not supported.",
|
||||
});
|
||||
}
|
||||
|
||||
if (string.Equals(rule.Operator, "regex", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = new System.Text.RegularExpressions.Regex(rule.Value);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
issues.Add(new WorkflowValidationIssueDto
|
||||
{
|
||||
Field = "graph.edges.condition.rules.value",
|
||||
EdgeId = edge.Id,
|
||||
Message = $"Regex pattern \"{rule.Value}\" is invalid.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasPathToAnyEnd(
|
||||
string startNodeId,
|
||||
WorkflowGraphDto graph,
|
||||
IEnumerable<string> endNodeIds)
|
||||
{
|
||||
HashSet<string> endSet = endNodeIds.ToHashSet(StringComparer.Ordinal);
|
||||
Dictionary<string, List<string>> outgoing = graph.Edges
|
||||
.GroupBy(edge => edge.Source, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group.Select(edge => edge.Target).ToList(),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
Queue<string> queue = new([startNodeId]);
|
||||
HashSet<string> visited = new(StringComparer.Ordinal);
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
string current = queue.Dequeue();
|
||||
if (!visited.Add(current))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (endSet.Contains(current))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (string target in outgoing.GetValueOrDefault(current, []))
|
||||
{
|
||||
queue.Enqueue(target);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool CanReachNode(
|
||||
WorkflowGraphDto graph,
|
||||
string startNodeId,
|
||||
string targetNodeId,
|
||||
string? excludedEdgeId = null)
|
||||
{
|
||||
if (string.Equals(startNodeId, targetNodeId, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
Dictionary<string, List<string>> outgoing = graph.Edges
|
||||
.Where(edge => !string.Equals(edge.Id, excludedEdgeId, StringComparison.Ordinal))
|
||||
.GroupBy(edge => edge.Source, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group.Select(edge => edge.Target).ToList(),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
Queue<string> queue = new([startNodeId]);
|
||||
HashSet<string> visited = new(StringComparer.Ordinal);
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
string current = queue.Dequeue();
|
||||
if (!visited.Add(current))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (string target in outgoing.GetValueOrDefault(current, []))
|
||||
{
|
||||
if (string.Equals(target, targetNodeId, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
queue.Enqueue(target);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsLoopEdge(WorkflowGraphDto graph, WorkflowEdgeDto edge)
|
||||
=> CanReachNode(graph, edge.Target, edge.Source, edge.Id);
|
||||
|
||||
private static HashSet<string> CollectStronglyConnectedNodes(WorkflowGraphDto graph, string nodeId)
|
||||
{
|
||||
HashSet<string> connected = new(StringComparer.Ordinal);
|
||||
foreach (WorkflowNodeDto candidate in graph.Nodes)
|
||||
{
|
||||
if (CanReachNode(graph, nodeId, candidate.Id) && CanReachNode(graph, candidate.Id, nodeId))
|
||||
{
|
||||
connected.Add(candidate.Id);
|
||||
}
|
||||
}
|
||||
|
||||
return connected;
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,14 @@ public sealed class AgentIdentityResolverTests
|
||||
[Fact]
|
||||
public void TryResolveKnownAgentIdentity_MatchesRuntimeExecutorIdentifier()
|
||||
{
|
||||
PatternDefinitionDto pattern = CreatePattern(
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow(
|
||||
[
|
||||
CreateAgent(id: "agent-concurrent-architect", name: "Architect"),
|
||||
CreateAgent(id: "agent-concurrent-product", name: "Product"),
|
||||
CreateAgent("agent-concurrent-architect", "Architect"),
|
||||
CreateAgent("agent-concurrent-product", "Product"),
|
||||
]);
|
||||
|
||||
bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity(
|
||||
pattern,
|
||||
workflow,
|
||||
"Architect_agent_concurrent_architect",
|
||||
out AgentIdentity agent);
|
||||
|
||||
@@ -27,14 +27,14 @@ public sealed class AgentIdentityResolverTests
|
||||
[Fact]
|
||||
public void TryResolveKnownAgentIdentity_MatchesSanitizedNameAndId()
|
||||
{
|
||||
PatternDefinitionDto pattern = CreatePattern(
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow(
|
||||
[
|
||||
CreateAgent(id: "agent-single-primary", name: "Primary Agent"),
|
||||
CreateAgent("agent-single-primary", "Primary Agent"),
|
||||
],
|
||||
mode: "single");
|
||||
orchestrationMode: "single");
|
||||
|
||||
bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity(
|
||||
pattern,
|
||||
workflow,
|
||||
"Primary_Agent_agent_single_primary",
|
||||
out AgentIdentity agent);
|
||||
|
||||
@@ -46,14 +46,14 @@ public sealed class AgentIdentityResolverTests
|
||||
[Fact]
|
||||
public void TryResolveKnownAgentIdentity_MapsAssistantToSingleAgent()
|
||||
{
|
||||
PatternDefinitionDto pattern = CreatePattern(
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow(
|
||||
[
|
||||
CreateAgent(id: "agent-single-primary", name: "Primary Agent"),
|
||||
CreateAgent("agent-single-primary", "Primary Agent"),
|
||||
],
|
||||
mode: "single");
|
||||
orchestrationMode: "single");
|
||||
|
||||
bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity(
|
||||
pattern,
|
||||
workflow,
|
||||
"assistant",
|
||||
out AgentIdentity agent);
|
||||
|
||||
@@ -63,16 +63,16 @@ public sealed class AgentIdentityResolverTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryResolveKnownAgentIdentity_DoesNotGuessAssistantForMultiAgentPattern()
|
||||
public void TryResolveKnownAgentIdentity_DoesNotGuessAssistantForMultiAgentWorkflow()
|
||||
{
|
||||
PatternDefinitionDto pattern = CreatePattern(
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow(
|
||||
[
|
||||
CreateAgent(id: "agent-concurrent-architect", name: "Architect"),
|
||||
CreateAgent(id: "agent-concurrent-product", name: "Product"),
|
||||
CreateAgent("agent-concurrent-architect", "Architect"),
|
||||
CreateAgent("agent-concurrent-product", "Product"),
|
||||
]);
|
||||
|
||||
bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity(
|
||||
pattern,
|
||||
workflow,
|
||||
"assistant",
|
||||
out _);
|
||||
|
||||
@@ -82,14 +82,14 @@ public sealed class AgentIdentityResolverTests
|
||||
[Fact]
|
||||
public void ResolveDisplayAuthorName_UsesCanonicalAgentName()
|
||||
{
|
||||
PatternDefinitionDto pattern = CreatePattern(
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow(
|
||||
[
|
||||
CreateAgent(id: "agent-concurrent-implementer", name: "Implementer"),
|
||||
CreateAgent("agent-concurrent-implementer", "Implementer"),
|
||||
],
|
||||
mode: "single");
|
||||
orchestrationMode: "single");
|
||||
|
||||
string authorName = AgentIdentityResolver.ResolveDisplayAuthorName(
|
||||
pattern,
|
||||
workflow,
|
||||
"Implementer_agent_concurrent_implementer");
|
||||
|
||||
Assert.Equal("Implementer", authorName);
|
||||
@@ -98,14 +98,14 @@ public sealed class AgentIdentityResolverTests
|
||||
[Fact]
|
||||
public void TryResolveObservedAgentIdentity_UsesFallbackAgentForGenericAssistant()
|
||||
{
|
||||
PatternDefinitionDto pattern = CreatePattern(
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow(
|
||||
[
|
||||
CreateAgent(id: "agent-handoff-ux", name: "UX Specialist"),
|
||||
CreateAgent(id: "agent-handoff-runtime", name: "Runtime Specialist"),
|
||||
CreateAgent("agent-handoff-ux", "UX Specialist"),
|
||||
CreateAgent("agent-handoff-runtime", "Runtime Specialist"),
|
||||
]);
|
||||
|
||||
bool resolved = AgentIdentityResolver.TryResolveObservedAgentIdentity(
|
||||
pattern,
|
||||
workflow,
|
||||
"assistant",
|
||||
new AgentIdentity("agent-handoff-ux", "UX Specialist"),
|
||||
out AgentIdentity agent);
|
||||
@@ -115,28 +115,147 @@ public sealed class AgentIdentityResolverTests
|
||||
Assert.Equal("UX Specialist", agent.AgentName);
|
||||
}
|
||||
|
||||
private static PatternDefinitionDto CreatePattern(
|
||||
IReadOnlyList<PatternAgentDefinitionDto> agents,
|
||||
string mode = "concurrent")
|
||||
[Fact]
|
||||
public void TryResolveKnownAgentIdentity_ResolvesReferencedSubworkflowAgentWithContext()
|
||||
{
|
||||
return new PatternDefinitionDto
|
||||
WorkflowDefinitionDto nestedWorkflow = CreateWorkflow(
|
||||
"nested-review-workflow",
|
||||
[
|
||||
CreateAgent("agent-reviewer", "Reviewer"),
|
||||
],
|
||||
orchestrationMode: "single");
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow(
|
||||
"parent-workflow",
|
||||
[
|
||||
CreateSubworkflow("subworkflow-review", "Review Lane", workflowId: nestedWorkflow.Id),
|
||||
],
|
||||
orchestrationMode: "single");
|
||||
|
||||
bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity(
|
||||
workflow,
|
||||
[nestedWorkflow],
|
||||
"Reviewer_agent_reviewer",
|
||||
out AgentIdentity agent);
|
||||
|
||||
Assert.True(resolved);
|
||||
Assert.Equal("agent-reviewer", agent.AgentId);
|
||||
Assert.Equal("Reviewer", agent.AgentName);
|
||||
Assert.Equal("subworkflow-review", agent.Subworkflow?.SubworkflowNodeId);
|
||||
Assert.Equal("Review Lane", agent.Subworkflow?.SubworkflowName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAgentSubworkflowIndex_UsesImmediateNestedSubworkflowContext()
|
||||
{
|
||||
WorkflowDefinitionDto innerWorkflow = CreateWorkflow(
|
||||
"inner-workflow",
|
||||
[
|
||||
CreateAgent("agent-inner-reviewer", "Inner Reviewer"),
|
||||
],
|
||||
orchestrationMode: "single");
|
||||
WorkflowDefinitionDto outerWorkflow = CreateWorkflow(
|
||||
"outer-workflow",
|
||||
[
|
||||
CreateSubworkflow("subworkflow-inner", "Inner Review", inlineWorkflow: innerWorkflow),
|
||||
],
|
||||
orchestrationMode: "single");
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow(
|
||||
"parent-workflow",
|
||||
[
|
||||
CreateSubworkflow("subworkflow-outer", "Outer Review", inlineWorkflow: outerWorkflow),
|
||||
],
|
||||
orchestrationMode: "single");
|
||||
|
||||
IReadOnlyDictionary<string, SubworkflowContext> index =
|
||||
AgentIdentityResolver.BuildAgentSubworkflowIndex(workflow);
|
||||
|
||||
Assert.True(index.TryGetValue("agent-inner-reviewer", out SubworkflowContext subworkflow));
|
||||
Assert.Equal("subworkflow-inner", subworkflow.SubworkflowNodeId);
|
||||
Assert.Equal("Inner Review", subworkflow.SubworkflowName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAgentSubworkflowIndex_SkipsUnresolvableSubWorkflowReferences()
|
||||
{
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow(
|
||||
"parent-workflow",
|
||||
[
|
||||
CreateAgent("agent-top-level", "Top Level"),
|
||||
CreateSubworkflow("subworkflow-missing", "Missing Pipeline", workflowId: "nonexistent-workflow"),
|
||||
],
|
||||
orchestrationMode: "concurrent");
|
||||
|
||||
IReadOnlyDictionary<string, SubworkflowContext> index =
|
||||
AgentIdentityResolver.BuildAgentSubworkflowIndex(workflow);
|
||||
|
||||
Assert.Empty(index);
|
||||
}
|
||||
|
||||
private static WorkflowDefinitionDto CreateWorkflow(
|
||||
IReadOnlyList<WorkflowNodeDto> nodes,
|
||||
string orchestrationMode = "concurrent")
|
||||
{
|
||||
return CreateWorkflow($"{orchestrationMode}-workflow", nodes, orchestrationMode);
|
||||
}
|
||||
|
||||
private static WorkflowDefinitionDto CreateWorkflow(
|
||||
string id,
|
||||
IReadOnlyList<WorkflowNodeDto> nodes,
|
||||
string orchestrationMode = "concurrent")
|
||||
{
|
||||
return new WorkflowDefinitionDto
|
||||
{
|
||||
Id = $"{mode}-pattern",
|
||||
Name = "Pattern",
|
||||
Mode = mode,
|
||||
Availability = "available",
|
||||
Agents = agents,
|
||||
Id = id,
|
||||
Name = "Workflow",
|
||||
Graph = new WorkflowGraphDto
|
||||
{
|
||||
Nodes =
|
||||
[
|
||||
.. nodes,
|
||||
],
|
||||
},
|
||||
Settings = new WorkflowSettingsDto
|
||||
{
|
||||
OrchestrationMode = orchestrationMode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static PatternAgentDefinitionDto CreateAgent(string id, string name)
|
||||
private static WorkflowNodeDto CreateAgent(string id, string name)
|
||||
{
|
||||
return new PatternAgentDefinitionDto
|
||||
return new WorkflowNodeDto
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help with the request.",
|
||||
Kind = "agent",
|
||||
Label = name,
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "agent",
|
||||
Id = id,
|
||||
Name = name,
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help with the request.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static WorkflowNodeDto CreateSubworkflow(
|
||||
string id,
|
||||
string label,
|
||||
string? workflowId = null,
|
||||
WorkflowDefinitionDto? inlineWorkflow = null)
|
||||
{
|
||||
return new WorkflowNodeDto
|
||||
{
|
||||
Id = id,
|
||||
Kind = "sub-workflow",
|
||||
Label = label,
|
||||
Config = new WorkflowNodeConfigDto
|
||||
{
|
||||
Kind = "sub-workflow",
|
||||
WorkflowId = workflowId,
|
||||
InlineWorkflow = inlineWorkflow,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,19 +8,10 @@ public sealed class AgentInstructionComposerTests
|
||||
[Fact]
|
||||
public void Compose_LeavesNonHandoffInstructionsUnchanged()
|
||||
{
|
||||
PatternDefinitionDto pattern = new()
|
||||
{
|
||||
Id = "pattern-sequential",
|
||||
Name = "Sequential",
|
||||
Mode = "sequential",
|
||||
Availability = "available",
|
||||
};
|
||||
PatternAgentDefinitionDto agent = CreateAgent(
|
||||
id: "agent-reviewer",
|
||||
name: "Reviewer",
|
||||
instructions: "Review the proposal.");
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow("sequential");
|
||||
WorkflowNodeDto agent = CreateAgent("agent-reviewer", "Reviewer", "Review the proposal.");
|
||||
|
||||
string instructions = AgentInstructionComposer.Compose(pattern, agent, agentIndex: 0);
|
||||
string instructions = AgentInstructionComposer.Compose(workflow, agent, agentIndex: 0);
|
||||
|
||||
Assert.Equal("Review the proposal.", instructions);
|
||||
}
|
||||
@@ -28,24 +19,12 @@ public sealed class AgentInstructionComposerTests
|
||||
[Fact]
|
||||
public void Compose_StrengthensGroupChatCollaborationRoles()
|
||||
{
|
||||
PatternDefinitionDto pattern = new()
|
||||
{
|
||||
Id = "pattern-group-chat",
|
||||
Name = "Group Chat",
|
||||
Mode = "group-chat",
|
||||
Availability = "available",
|
||||
};
|
||||
PatternAgentDefinitionDto writer = CreateAgent(
|
||||
id: "agent-group-writer",
|
||||
name: "Writer",
|
||||
instructions: "Draft an answer.");
|
||||
PatternAgentDefinitionDto reviewer = CreateAgent(
|
||||
id: "agent-group-reviewer",
|
||||
name: "Reviewer",
|
||||
instructions: "Review the draft.");
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow("group-chat");
|
||||
WorkflowNodeDto writer = CreateAgent("agent-group-writer", "Writer", "Draft an answer.");
|
||||
WorkflowNodeDto reviewer = CreateAgent("agent-group-reviewer", "Reviewer", "Review the draft.");
|
||||
|
||||
string writerInstructions = AgentInstructionComposer.Compose(pattern, writer, agentIndex: 0);
|
||||
string reviewerInstructions = AgentInstructionComposer.Compose(pattern, reviewer, agentIndex: 1);
|
||||
string writerInstructions = AgentInstructionComposer.Compose(workflow, writer, agentIndex: 0);
|
||||
string reviewerInstructions = AgentInstructionComposer.Compose(workflow, reviewer, agentIndex: 1);
|
||||
|
||||
Assert.Contains("collaborative multi-turn group chat", writerInstructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("refine your earlier draft", writerInstructions, StringComparison.OrdinalIgnoreCase);
|
||||
@@ -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");
|
||||
@@ -124,14 +82,111 @@ public sealed class AgentInstructionComposerTests
|
||||
Assert.DoesNotContain("Do not inspect, modify, create, or delete files", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static PatternAgentDefinitionDto CreateAgent(string id, string name, string instructions)
|
||||
[Fact]
|
||||
public void Compose_AddsPlanModeGuidanceWhenRequested()
|
||||
{
|
||||
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,
|
||||
interactionMode: "plan");
|
||||
|
||||
Assert.Contains("operating in plan mode", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("produce a concrete implementation plan", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("exit_plan_mode", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Do not continue into implementation", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_InsertsProjectInstructionsBetweenBaseAndRuntimeGuidance()
|
||||
{
|
||||
WorkflowDefinitionDto workflow = CreateWorkflow("single");
|
||||
WorkflowNodeDto agent = CreateAgent("agent-primary", "Primary Agent", "You are a helpful assistant.");
|
||||
|
||||
string instructions = AgentInstructionComposer.Compose(
|
||||
workflow,
|
||||
agent,
|
||||
agentIndex: 0,
|
||||
workspaceKind: "scratchpad",
|
||||
projectInstructions: "Follow the repository guide.");
|
||||
|
||||
Assert.Contains("You are a helpful assistant.", instructions, StringComparison.Ordinal);
|
||||
Assert.Contains("Follow the repository guide.", instructions, StringComparison.Ordinal);
|
||||
Assert.Contains("scratchpad mode", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.True(
|
||||
instructions.IndexOf("You are a helpful assistant.", StringComparison.Ordinal)
|
||||
< instructions.IndexOf("Follow the repository guide.", StringComparison.Ordinal));
|
||||
Assert.True(
|
||||
instructions.IndexOf("Follow the repository guide.", StringComparison.Ordinal)
|
||||
< instructions.IndexOf("scratchpad mode", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_AppendsPromptInvocationAsATaskDirective()
|
||||
{
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class AryxCopilotAgentMessageOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ProcessMessageAttachmentsAsync_MapsProtocolAttachmentsAndMessageMode()
|
||||
{
|
||||
string attachmentPath = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "aryx-tests", "assets", "diagram.png"));
|
||||
ChatMessage message = new(ChatRole.User, "Please inspect these images.");
|
||||
message.Contents.Add(new AIContent
|
||||
{
|
||||
RawRepresentation = new ChatMessageAttachmentDto
|
||||
{
|
||||
Type = "file",
|
||||
Path = attachmentPath,
|
||||
DisplayName = "diagram.png",
|
||||
},
|
||||
});
|
||||
message.Contents.Add(new AIContent
|
||||
{
|
||||
RawRepresentation = new ChatMessageAttachmentDto
|
||||
{
|
||||
Type = "blob",
|
||||
Data = "QUJDRA==",
|
||||
MimeType = "image/png",
|
||||
DisplayName = "clipboard.png",
|
||||
},
|
||||
});
|
||||
message.Contents.Add(new AIContent
|
||||
{
|
||||
RawRepresentation = new CopilotMessageOptionsMetadata("immediate"),
|
||||
});
|
||||
|
||||
(List<UserMessageDataAttachmentsItem>? attachments, string? messageMode, string? tempDir) =
|
||||
await AryxCopilotAgent.ProcessMessageAttachmentsAsync([message], CancellationToken.None);
|
||||
|
||||
Assert.Equal("immediate", messageMode);
|
||||
Assert.Null(tempDir);
|
||||
|
||||
Assert.NotNull(attachments);
|
||||
Assert.Collection(
|
||||
attachments!,
|
||||
first =>
|
||||
{
|
||||
UserMessageDataAttachmentsItemFile file = Assert.IsType<UserMessageDataAttachmentsItemFile>(first);
|
||||
Assert.Equal(attachmentPath, file.Path);
|
||||
Assert.Equal("diagram.png", file.DisplayName);
|
||||
},
|
||||
second =>
|
||||
{
|
||||
UserMessageDataAttachmentsItemBlob blob = Assert.IsType<UserMessageDataAttachmentsItemBlob>(second);
|
||||
Assert.Equal("QUJDRA==", blob.Data);
|
||||
Assert.Equal("image/png", blob.MimeType);
|
||||
Assert.Equal("clipboard.png", blob.DisplayName);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessageAttachmentsAsync_RejectsRelativeFileAttachments()
|
||||
{
|
||||
ChatMessage message = new(ChatRole.User, "Inspect this file.");
|
||||
message.Contents.Add(new AIContent
|
||||
{
|
||||
RawRepresentation = new ChatMessageAttachmentDto
|
||||
{
|
||||
Type = "file",
|
||||
Path = "relative\\image.png",
|
||||
},
|
||||
});
|
||||
|
||||
InvalidOperationException error = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
AryxCopilotAgent.ProcessMessageAttachmentsAsync([message], CancellationToken.None));
|
||||
|
||||
Assert.Contains("absolute", error.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,34 @@
|
||||
using System.Reflection;
|
||||
using Aryx.AgentHost.Services;
|
||||
using System.Text.Json;
|
||||
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()
|
||||
{
|
||||
@@ -50,7 +72,555 @@ public sealed class CopilotAgentBundleTests
|
||||
Assert.Equal(["glob", "view"], sessionConfig.AvailableTools);
|
||||
}
|
||||
|
||||
private static AIFunction CreateTool()
|
||||
[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()
|
||||
{
|
||||
CopilotAgentBundle bundle = new([], hasConfiguredHooks: true);
|
||||
|
||||
Assert.True(bundle.HasConfiguredHooks);
|
||||
Assert.Empty(bundle.Agents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateConfiguredSessionConfig_MergesInstructionsAndConvertsHandoffDeclarations()
|
||||
{
|
||||
SessionConfig baseConfig = new()
|
||||
{
|
||||
Model = "gpt-5.4",
|
||||
SystemMessage = new SystemMessageConfig
|
||||
{
|
||||
Content = "Base instructions",
|
||||
},
|
||||
Tools = [CreateTool()],
|
||||
};
|
||||
ChatClientAgentRunOptions options = new(new ChatOptions
|
||||
{
|
||||
Instructions = "Workflow handoff instructions",
|
||||
Tools = [CreateHandoffDeclaration()],
|
||||
});
|
||||
|
||||
SessionConfig effective = AryxCopilotAgent.CreateConfiguredSessionConfig(baseConfig, options);
|
||||
|
||||
Assert.Equal("gpt-5.4", effective.Model);
|
||||
Assert.Equal("Base instructions\n\nWorkflow handoff instructions", effective.SystemMessage?.Content);
|
||||
Assert.Equal("Base instructions", baseConfig.SystemMessage?.Content);
|
||||
|
||||
AIFunction[] tools = Assert.IsAssignableFrom<IEnumerable<AIFunction>>(effective.Tools).ToArray();
|
||||
Assert.Equal(2, tools.Length);
|
||||
AIFunction handoffTool = Assert.Single(tools, tool => tool.Name == "handoff_to_1");
|
||||
Assert.True(handoffTool.AdditionalProperties.TryGetValue("skip_permission", out object? skipPermission));
|
||||
Assert.Equal(true, skipPermission);
|
||||
|
||||
object? result = await handoffTool.InvokeAsync(new AIFunctionArguments
|
||||
{
|
||||
["reasonForHandoff"] = "UI specialist",
|
||||
});
|
||||
|
||||
Assert.Equal("Transferred.", result?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateConfiguredSessionConfig_RejectsUnsupportedRuntimeDeclarations()
|
||||
{
|
||||
ChatClientAgentRunOptions options = new(new ChatOptions
|
||||
{
|
||||
Tools = [AIFunctionFactory.CreateDeclaration("route_elsewhere", "Unsupported declaration", CreateTool().JsonSchema)],
|
||||
});
|
||||
|
||||
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()
|
||||
{
|
||||
AssistantMessageDataToolRequestsItem[] toolRequests =
|
||||
{
|
||||
new()
|
||||
{
|
||||
ToolCallId = "call-123",
|
||||
Name = "handoff_to_1",
|
||||
Arguments = JsonSerializer.SerializeToElement(new Dictionary<string, object?>
|
||||
{
|
||||
["reasonForHandoff"] = "frontend specialist",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
FunctionCallContent functionCall = Assert.Single(AryxCopilotAgent.ConvertToolRequestsToFunctionCalls(toolRequests));
|
||||
|
||||
Assert.Equal("call-123", functionCall.CallId);
|
||||
Assert.Equal("handoff_to_1", functionCall.Name);
|
||||
Assert.NotNull(functionCall.Arguments);
|
||||
Assert.Equal("frontend specialist", functionCall.Arguments["reasonForHandoff"]?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToolRequestsToFunctionCalls_MapsNonHandoffToolCalls()
|
||||
{
|
||||
AssistantMessageDataToolRequestsItem[] toolRequests =
|
||||
{
|
||||
new() { ToolCallId = "call-001", Name = "ask_user" },
|
||||
new() { ToolCallId = "call-002", Name = "web_fetch" },
|
||||
new() { ToolCallId = "call-003", Name = "handoff_to_reviewer" },
|
||||
new() { ToolCallId = "call-004", Name = "grep" },
|
||||
};
|
||||
|
||||
IReadOnlyList<FunctionCallContent> result = AryxCopilotAgent.ConvertToolRequestsToFunctionCalls(toolRequests);
|
||||
|
||||
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]
|
||||
public void CreateCustomAgents_MapsSdkCustomAgentConfiguration()
|
||||
{
|
||||
List<CustomAgentConfig> customAgents = Assert.IsType<List<CustomAgentConfig>>(CopilotAgentBundle.CreateCustomAgents(
|
||||
[
|
||||
new RunTurnCustomAgentConfigDto
|
||||
{
|
||||
Name = "designer",
|
||||
DisplayName = "Designer",
|
||||
Description = "Design specialist",
|
||||
Tools = ["view", "glob"],
|
||||
Prompt = "Focus on UX design.",
|
||||
Infer = true,
|
||||
McpServers =
|
||||
[
|
||||
new RunTurnMcpServerConfigDto
|
||||
{
|
||||
Id = "designer-mcp",
|
||||
Name = "Designer MCP",
|
||||
Transport = "local",
|
||||
Command = "node",
|
||||
Args = ["designer.js"],
|
||||
},
|
||||
],
|
||||
},
|
||||
]));
|
||||
|
||||
CustomAgentConfig customAgent = Assert.Single(customAgents);
|
||||
Assert.Equal("designer", customAgent.Name);
|
||||
Assert.Equal("Designer", customAgent.DisplayName);
|
||||
Assert.Equal("Design specialist", customAgent.Description);
|
||||
Assert.Equal(["view", "glob"], customAgent.Tools);
|
||||
Assert.Equal("Focus on UX design.", customAgent.Prompt);
|
||||
Assert.True(customAgent.Infer);
|
||||
|
||||
KeyValuePair<string, object> mcpServer = Assert.Single(customAgent.McpServers!);
|
||||
Assert.Equal("Designer MCP", mcpServer.Key);
|
||||
McpLocalServerConfig localServer = Assert.IsType<McpLocalServerConfig>(mcpServer.Value);
|
||||
Assert.Equal("node", localServer.Command);
|
||||
Assert.Equal(["designer.js"], localServer.Args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInfiniteSessions_MapsSdkInfiniteSessionConfiguration()
|
||||
{
|
||||
InfiniteSessionConfig config = Assert.IsType<InfiniteSessionConfig>(CopilotAgentBundle.CreateInfiniteSessions(
|
||||
new RunTurnInfiniteSessionsConfigDto
|
||||
{
|
||||
Enabled = true,
|
||||
BackgroundCompactionThreshold = 0.75,
|
||||
BufferExhaustionThreshold = 0.9,
|
||||
}));
|
||||
|
||||
Assert.True(config.Enabled);
|
||||
Assert.Equal(0.75, config.BackgroundCompactionThreshold);
|
||||
Assert.Equal(0.9, config.BufferExhaustionThreshold);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSessionConfig_DoesNotForceSessionId()
|
||||
{
|
||||
RunTurnCommandDto command = new()
|
||||
{
|
||||
SessionId = "session-1",
|
||||
ProjectPath = @"C:\workspace\project",
|
||||
WorkspaceKind = "project",
|
||||
Mode = "interactive",
|
||||
Workflow = CreateWorkflow("single", 1),
|
||||
};
|
||||
|
||||
SessionConfig sessionConfig = CopilotAgentBundle.CreateSessionConfig(
|
||||
command,
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
agentIndex: 0);
|
||||
|
||||
Assert.Null(sessionConfig.SessionId);
|
||||
Assert.Equal(@"C:\workspace\project", sessionConfig.WorkingDirectory);
|
||||
Assert.True(sessionConfig.Streaming);
|
||||
Assert.NotNull(sessionConfig.Hooks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSessionConfig_PassesProjectInstructionsIntoTheSystemMessage()
|
||||
{
|
||||
RunTurnCommandDto command = new()
|
||||
{
|
||||
SessionId = "session-1",
|
||||
ProjectPath = @"C:\workspace\project",
|
||||
WorkspaceKind = "project",
|
||||
Mode = "interactive",
|
||||
ProjectInstructions = "Follow repository guidance.",
|
||||
Workflow = CreateWorkflow("single", 1),
|
||||
};
|
||||
|
||||
SessionConfig sessionConfig = CopilotAgentBundle.CreateSessionConfig(
|
||||
command,
|
||||
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()
|
||||
{
|
||||
RunTurnCommandDto command = new()
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Workflow = CreateWorkflow(
|
||||
"single",
|
||||
1,
|
||||
new ApprovalPolicyDto
|
||||
{
|
||||
Rules =
|
||||
[
|
||||
new ApprovalCheckpointRuleDto
|
||||
{
|
||||
Kind = "tool-call",
|
||||
AgentIds = ["agent-1"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0]);
|
||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||
new PreToolUseHookInput
|
||||
{
|
||||
ToolName = "view",
|
||||
},
|
||||
null!);
|
||||
|
||||
Assert.Equal("ask", decision?.PermissionDecision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CopilotManagedSessionIds_BuildsAndParsesStableIds()
|
||||
{
|
||||
string sessionId = CopilotManagedSessionIds.Build("session-1", "agent-ux");
|
||||
|
||||
Assert.True(CopilotManagedSessionIds.TryParse(sessionId, out string aryxSessionId, out string agentId));
|
||||
Assert.Equal("session-1", aryxSessionId);
|
||||
Assert.Equal("agent-ux", agentId);
|
||||
}
|
||||
|
||||
private static AIFunction CreateTool(string name = "echo")
|
||||
{
|
||||
ToolTarget target = new();
|
||||
MethodInfo method = typeof(ToolTarget).GetMethod(nameof(ToolTarget.Echo))
|
||||
@@ -61,13 +631,226 @@ 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(
|
||||
"handoff_to_1",
|
||||
"Transfer ownership to a specialist",
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class CopilotExitPlanModeCoordinatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void RecordExitPlanModeRequest_BuildsEventAndMakesItConsumable()
|
||||
{
|
||||
CopilotExitPlanModeCoordinator coordinator = new();
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
|
||||
ExitPlanModeRequestedEventDto exitPlanEvent = coordinator.RecordExitPlanModeRequest(
|
||||
command,
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
new ExitPlanModeRequestedEvent
|
||||
{
|
||||
Data = new ExitPlanModeRequestedData
|
||||
{
|
||||
RequestId = "exit-plan-1",
|
||||
Summary = "Proposed plan",
|
||||
PlanContent = "1. Investigate\n2. Implement",
|
||||
Actions = ["interactive", "autopilot"],
|
||||
RecommendedAction = "interactive",
|
||||
},
|
||||
});
|
||||
|
||||
Assert.Equal("exit-plan-mode-requested", exitPlanEvent.Type);
|
||||
Assert.Equal("turn-1", exitPlanEvent.RequestId);
|
||||
Assert.Equal("session-1", exitPlanEvent.SessionId);
|
||||
Assert.Equal("exit-plan-1", exitPlanEvent.ExitPlanId);
|
||||
Assert.Equal("agent-1", exitPlanEvent.AgentId);
|
||||
Assert.Equal("Primary", exitPlanEvent.AgentName);
|
||||
Assert.Equal("Proposed plan", exitPlanEvent.Summary);
|
||||
Assert.Equal("1. Investigate\n2. Implement", exitPlanEvent.PlanContent);
|
||||
Assert.Equal(["interactive", "autopilot"], exitPlanEvent.Actions);
|
||||
Assert.Equal("interactive", exitPlanEvent.RecommendedAction);
|
||||
|
||||
ExitPlanModeRequestedEventDto? consumed = coordinator.ConsumePendingRequest(command.RequestId);
|
||||
Assert.NotNull(consumed);
|
||||
Assert.Equal("exit-plan-1", consumed!.ExitPlanId);
|
||||
Assert.Null(coordinator.ConsumePendingRequest(command.RequestId));
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateCommand()
|
||||
{
|
||||
return new RunTurnCommandDto
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Workflow = new WorkflowDefinitionDto
|
||||
{
|
||||
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",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class CopilotMcpOAuthCoordinatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildMcpOauthRequiredEvent_MapsSdkEventToProtocolEvent()
|
||||
{
|
||||
CopilotMcpOAuthCoordinator coordinator = new();
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
|
||||
McpOauthRequiredEventDto oauthEvent = coordinator.BuildMcpOauthRequiredEvent(
|
||||
command,
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
new McpOauthRequiredEvent
|
||||
{
|
||||
Data = new McpOauthRequiredData
|
||||
{
|
||||
RequestId = " oauth-request-1 ",
|
||||
ServerName = " Example MCP ",
|
||||
ServerUrl = " https://example.com/mcp ",
|
||||
StaticClientConfig = new McpOauthRequiredDataStaticClientConfig
|
||||
{
|
||||
ClientId = " aryx-client ",
|
||||
PublicClient = true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Assert.Equal("mcp-oauth-required", oauthEvent.Type);
|
||||
Assert.Equal("turn-1", oauthEvent.RequestId);
|
||||
Assert.Equal("session-1", oauthEvent.SessionId);
|
||||
Assert.Equal("oauth-request-1", oauthEvent.OauthRequestId);
|
||||
Assert.Equal("agent-1", oauthEvent.AgentId);
|
||||
Assert.Equal("Primary", oauthEvent.AgentName);
|
||||
Assert.Equal("Example MCP", oauthEvent.ServerName);
|
||||
Assert.Equal("https://example.com/mcp", oauthEvent.ServerUrl);
|
||||
Assert.NotNull(oauthEvent.StaticClientConfig);
|
||||
Assert.Equal("aryx-client", oauthEvent.StaticClientConfig!.ClientId);
|
||||
Assert.True(oauthEvent.StaticClientConfig.PublicClient);
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateCommand()
|
||||
{
|
||||
return new RunTurnCommandDto
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Workflow = new WorkflowDefinitionDto
|
||||
{
|
||||
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",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
using System.Text.Json;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class CopilotSessionHooksTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Create_FileBasedPreToolUseDenyOverridesApprovalPolicy()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommandWithToolApproval();
|
||||
RecordingHookCommandRunner runner = new(
|
||||
[
|
||||
"""{"permissionDecision":"deny","permissionDecisionReason":"Blocked by repository hook"}""",
|
||||
]);
|
||||
ResolvedHookSet configuredHooks = new()
|
||||
{
|
||||
PreToolUse =
|
||||
[
|
||||
CreateHookCommand("deny-pre-tool"),
|
||||
],
|
||||
};
|
||||
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], configuredHooks, runner);
|
||||
|
||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||
new PreToolUseHookInput
|
||||
{
|
||||
Timestamp = 1710000000000,
|
||||
Cwd = command.ProjectPath,
|
||||
ToolName = "view",
|
||||
ToolArgs = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
path = "README.md",
|
||||
}),
|
||||
},
|
||||
null!);
|
||||
|
||||
Assert.Equal("deny", decision?.PermissionDecision);
|
||||
Assert.Equal("Blocked by repository hook", decision?.PermissionDecisionReason);
|
||||
|
||||
RecordedHookInvocation invocation = Assert.Single(runner.Invocations);
|
||||
JsonDocument payload = JsonDocument.Parse(invocation.InputJson);
|
||||
Assert.Equal("view", payload.RootElement.GetProperty("toolName").GetString());
|
||||
Assert.Equal("{\"path\":\"README.md\"}", payload.RootElement.GetProperty("toolArgs").GetString());
|
||||
Assert.Equal(command.ProjectPath, invocation.ProjectPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_PreToolUseFallsThroughWhenFileHooksDoNotDeny()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommandWithToolApproval();
|
||||
RecordingHookCommandRunner runner = new(
|
||||
[
|
||||
"""{"permissionDecision":"allow"}""",
|
||||
]);
|
||||
ResolvedHookSet configuredHooks = new()
|
||||
{
|
||||
PreToolUse =
|
||||
[
|
||||
CreateHookCommand("allow-pre-tool"),
|
||||
],
|
||||
};
|
||||
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], configuredHooks, runner);
|
||||
|
||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||
new PreToolUseHookInput
|
||||
{
|
||||
ToolName = "view",
|
||||
},
|
||||
null!);
|
||||
|
||||
Assert.Equal("ask", decision?.PermissionDecision);
|
||||
Assert.Single(runner.Invocations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_PreToolUseIgnoresInvalidHookOutputAndFallsThrough()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommandWithToolApproval();
|
||||
RecordingHookCommandRunner runner = new(
|
||||
[
|
||||
"not-json",
|
||||
]);
|
||||
ResolvedHookSet configuredHooks = new()
|
||||
{
|
||||
PreToolUse =
|
||||
[
|
||||
CreateHookCommand("invalid-pre-tool"),
|
||||
],
|
||||
};
|
||||
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], configuredHooks, runner);
|
||||
|
||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||
new PreToolUseHookInput
|
||||
{
|
||||
ToolName = "view",
|
||||
},
|
||||
null!);
|
||||
|
||||
Assert.Equal("ask", decision?.PermissionDecision);
|
||||
Assert.Single(runner.Invocations);
|
||||
}
|
||||
|
||||
[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_PreToolUseAutoAllowsInternalOrchestrationTools(string toolName)
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommandWithToolApproval();
|
||||
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_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()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommandWithoutApprovalRules();
|
||||
RecordingHookCommandRunner runner = new();
|
||||
ResolvedHookSet configuredHooks = new()
|
||||
{
|
||||
SessionStart = [CreateHookCommand("session-start-hook")],
|
||||
UserPromptSubmitted = [CreateHookCommand("prompt-hook")],
|
||||
PostToolUse = [CreateHookCommand("post-tool-hook")],
|
||||
SessionEnd = [CreateHookCommand("session-end-hook")],
|
||||
ErrorOccurred = [CreateHookCommand("error-hook")],
|
||||
};
|
||||
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], configuredHooks, runner);
|
||||
|
||||
await hooks.OnSessionStart!(
|
||||
new SessionStartHookInput
|
||||
{
|
||||
Timestamp = 1,
|
||||
Cwd = command.ProjectPath,
|
||||
Source = "new",
|
||||
InitialPrompt = "Create the feature",
|
||||
},
|
||||
null!);
|
||||
await hooks.OnUserPromptSubmitted!(
|
||||
new UserPromptSubmittedHookInput
|
||||
{
|
||||
Timestamp = 2,
|
||||
Cwd = command.ProjectPath,
|
||||
Prompt = "Refactor the API",
|
||||
},
|
||||
null!);
|
||||
await hooks.OnPostToolUse!(
|
||||
new PostToolUseHookInput
|
||||
{
|
||||
Timestamp = 3,
|
||||
Cwd = command.ProjectPath,
|
||||
ToolName = "view",
|
||||
ToolArgs = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
path = "README.md",
|
||||
}),
|
||||
ToolResult = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
resultType = "success",
|
||||
textResultForLlm = "Read 1 file",
|
||||
}),
|
||||
},
|
||||
null!);
|
||||
await hooks.OnSessionEnd!(
|
||||
new SessionEndHookInput
|
||||
{
|
||||
Timestamp = 4,
|
||||
Cwd = command.ProjectPath,
|
||||
Reason = "complete",
|
||||
FinalMessage = "Done",
|
||||
},
|
||||
null!);
|
||||
await hooks.OnErrorOccurred!(
|
||||
new ErrorOccurredHookInput
|
||||
{
|
||||
Timestamp = 5,
|
||||
Cwd = command.ProjectPath,
|
||||
Error = "Network timeout",
|
||||
ErrorContext = "tool_execution",
|
||||
Recoverable = true,
|
||||
},
|
||||
null!);
|
||||
|
||||
Assert.Equal(
|
||||
["session-start-hook", "prompt-hook", "post-tool-hook", "session-end-hook", "error-hook"],
|
||||
runner.Invocations.Select(invocation => GetCommandText(invocation.Hook)).ToArray());
|
||||
|
||||
JsonDocument postToolPayload = JsonDocument.Parse(runner.Invocations[2].InputJson);
|
||||
Assert.Equal("view", postToolPayload.RootElement.GetProperty("toolName").GetString());
|
||||
Assert.Equal("success", postToolPayload.RootElement.GetProperty("toolResult").GetProperty("resultType").GetString());
|
||||
|
||||
JsonDocument errorPayload = JsonDocument.Parse(runner.Invocations[4].InputJson);
|
||||
Assert.Equal("Network timeout", errorPayload.RootElement.GetProperty("error").GetProperty("message").GetString());
|
||||
Assert.Equal("tool_execution", errorPayload.RootElement.GetProperty("error").GetProperty("context").GetString());
|
||||
Assert.True(errorPayload.RootElement.GetProperty("error").GetProperty("recoverable").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithoutConfiguredFileHooksPreservesExistingApprovalBehavior()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommandWithoutApprovalRules();
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
||||
|
||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||
new PreToolUseHookInput
|
||||
{
|
||||
ToolName = "view",
|
||||
},
|
||||
null!);
|
||||
|
||||
Assert.Equal("allow", decision?.PermissionDecision);
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateCommandWithToolApproval()
|
||||
{
|
||||
return new RunTurnCommandDto
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
ProjectPath = @"C:\workspace\project",
|
||||
Workflow = CreateWorkflow(new ApprovalPolicyDto
|
||||
{
|
||||
Rules =
|
||||
[
|
||||
new ApprovalCheckpointRuleDto
|
||||
{
|
||||
Kind = "tool-call",
|
||||
AgentIds = ["agent-1"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateCommandWithoutApprovalRules()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommandWithToolApproval();
|
||||
return new RunTurnCommandDto
|
||||
{
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
ProjectPath = command.ProjectPath,
|
||||
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
|
||||
{
|
||||
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()
|
||||
{
|
||||
Type = "command",
|
||||
Bash = name,
|
||||
PowerShell = name,
|
||||
};
|
||||
|
||||
private static string GetCommandText(HookCommandDefinition hook)
|
||||
=> hook.PowerShell ?? hook.Bash ?? string.Empty;
|
||||
|
||||
private sealed class RecordingHookCommandRunner : IHookCommandRunner
|
||||
{
|
||||
private readonly Queue<string?> _outputs;
|
||||
|
||||
public List<RecordedHookInvocation> Invocations { get; } = [];
|
||||
|
||||
public RecordingHookCommandRunner(IEnumerable<string?>? outputs = null)
|
||||
{
|
||||
_outputs = outputs is null ? new Queue<string?>() : new Queue<string?>(outputs);
|
||||
}
|
||||
|
||||
public Task<string?> RunAsync(
|
||||
HookCommandDefinition hook,
|
||||
string inputJson,
|
||||
string projectPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Invocations.Add(new RecordedHookInvocation(hook, inputJson, projectPath));
|
||||
return Task.FromResult(_outputs.Count > 0 ? _outputs.Dequeue() : string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record RecordedHookInvocation(
|
||||
HookCommandDefinition Hook,
|
||||
string InputJson,
|
||||
string ProjectPath);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class CopilotUserInputCoordinatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RequestUserInputAsync_RaisesUserInputEventAndCompletesAfterResolution()
|
||||
{
|
||||
CopilotUserInputCoordinator coordinator = new();
|
||||
UserInputRequestedEventDto? observedEvent = null;
|
||||
RunTurnCommandDto command = CreateUserInputCommand();
|
||||
|
||||
Task<UserInputResponse> pending = coordinator.RequestUserInputAsync(
|
||||
command,
|
||||
command.Workflow.GetAgentNodes()[0],
|
||||
new UserInputRequest
|
||||
{
|
||||
Question = "How should I proceed?",
|
||||
Choices = ["Continue", "Stop"],
|
||||
AllowFreeform = true,
|
||||
},
|
||||
new UserInputInvocation
|
||||
{
|
||||
SessionId = "copilot-session-1",
|
||||
},
|
||||
userInputEvent =>
|
||||
{
|
||||
observedEvent = userInputEvent;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(pending.IsCompleted);
|
||||
Assert.NotNull(observedEvent);
|
||||
Assert.Equal("user-input-requested", observedEvent!.Type);
|
||||
Assert.Equal("turn-1", observedEvent.RequestId);
|
||||
Assert.Equal("session-1", observedEvent.SessionId);
|
||||
Assert.Equal("agent-1", observedEvent.AgentId);
|
||||
Assert.Equal("Primary", observedEvent.AgentName);
|
||||
Assert.Equal("How should I proceed?", observedEvent.Question);
|
||||
Assert.Equal(["Continue", "Stop"], observedEvent.Choices);
|
||||
Assert.True(observedEvent.AllowFreeform);
|
||||
|
||||
await coordinator.ResolveUserInputAsync(
|
||||
new ResolveUserInputCommandDto
|
||||
{
|
||||
UserInputId = observedEvent.UserInputId,
|
||||
Answer = "Continue",
|
||||
WasFreeform = false,
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
UserInputResponse response = await pending;
|
||||
Assert.Equal("Continue", response.Answer);
|
||||
Assert.False(response.WasFreeform);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveUserInputAsync_RejectsUnknownUserInputIds()
|
||||
{
|
||||
CopilotUserInputCoordinator coordinator = new();
|
||||
|
||||
InvalidOperationException error = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
coordinator.ResolveUserInputAsync(
|
||||
new ResolveUserInputCommandDto
|
||||
{
|
||||
UserInputId = "user-input-missing",
|
||||
Answer = "Continue",
|
||||
WasFreeform = false,
|
||||
},
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.Contains("is not pending", error.Message);
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateUserInputCommand()
|
||||
{
|
||||
return new RunTurnCommandDto
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Workflow = new WorkflowDefinitionDto
|
||||
{
|
||||
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",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user