Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ jobs:
./actionlint -color
- name: Version surfaces agree
run: python3 scripts/check-version-consistency.py
# The parity harness proves the bindings agree about the calls it
# makes. It cannot notice a capability none of them expose, because
# a surface absent everywhere is consistent everywhere. That blind
# spot is how streaming shipped reachable only from Rust.
- name: Every engine capability is reachable from every language
run: python3 tests/conformance/bindings/run.py language_coverage
- name: cargo-audit over example lockfiles (pinned, checksum-verified)
# Examples are deliberately outside the Dependabot update config,
# and the coding_agent app's path dependency predates the engine
Expand Down Expand Up @@ -151,3 +157,74 @@ jobs:
working-directory: sdk/dotnet
env:
LD_LIBRARY_PATH: ${{ github.workspace }}/target/release
# The suite above runs with the engine on the loader path, which a
# consumer installing from NuGet does not have. Pack and run the
# artifact itself so a package that omits the native library fails
# here rather than in someone else's process.
- name: Verify the packed package works without a local engine build
run: |
set -euo pipefail
mkdir -p sdk/dotnet/native/runtimes/linux-x64/native
cp target/release/libagent_control_spec_ffi.so \
sdk/dotnet/native/runtimes/linux-x64/native/
dotnet pack sdk/dotnet/src/AgentControlSpec -c Release -o "$PWD/ci-feed" \
--nologo -p:AcsNativeAssetsRequired=true -p:Version=0.0.0-ci
bash tests/conformance/bindings/dotnet_package.sh "$PWD/ci-feed" 0.0.0-ci

# Streaming reaches each language through a different binding
# mechanism, so agreement between them is not structural. Run one
# scenario in all four and diff.
streaming-parity:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: "8.0.x"
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "22"
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- run: cargo build --release --locked -p agent-control-spec-ffi
- name: Build the node binding
run: |
npm ci
npm run build
working-directory: sdk/node
- name: Build and install the python wheel
run: |
pip install maturin==1.8.7
maturin build --release -m sdk/python/Cargo.toml -o dist
pip install dist/*.whl
- name: Compare every language across the whole surface
run: python tests/conformance/bindings/run.py cross_language_parity
env:
LD_LIBRARY_PATH: ${{ github.workspace }}/target/release

# The suites and the parity harness run against this checkout, where the
# engine is on the loader path and the packages are importable from
# source. A consumer has a crate, a wheel, a tarball and a nupkg. A
# published .NET package once passed every test and threw
# DllNotFoundException on the first call a consumer made, so build the
# real artifacts and run the surface from a clean install of each.
artifacts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: "8.0.x"
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "22"
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- run: pip install maturin==1.8.7
- run: npm ci
working-directory: sdk/node
- run: python tests/conformance/bindings/run.py published_artifacts
61 changes: 60 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,43 @@ jobs:
fi
working-directory: sdk/node

# The managed assembly calls the engine through agent_control_spec_ffi,
# so a package without the native library installs cleanly and then
# throws DllNotFoundException on the first call. Build one library per
# supported RID and let the pack step assemble them.
dotnet-native:
strategy:
fail-fast: false
matrix:
include:
- { os: ubuntu-latest, target: x86_64-unknown-linux-gnu, rid: linux-x64, lib: libagent_control_spec_ffi.so }
- { os: ubuntu-24.04-arm, target: aarch64-unknown-linux-gnu, rid: linux-arm64, lib: libagent_control_spec_ffi.so }
- { os: macos-latest, target: x86_64-apple-darwin, rid: osx-x64, lib: libagent_control_spec_ffi.dylib }
- { os: macos-latest, target: aarch64-apple-darwin, rid: osx-arm64, lib: libagent_control_spec_ffi.dylib }
- { os: windows-latest, target: x86_64-pc-windows-msvc, rid: win-x64, lib: agent_control_spec_ffi.dll }
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master; stable toolchain
with:
toolchain: stable
targets: ${{ matrix.target }}
- name: Build the engine binding for ${{ matrix.rid }}
shell: bash
run: cargo build --release --locked -p agent-control-spec-ffi --target ${{ matrix.target }}
- name: Stage under its runtime identifier
shell: bash
run: |
mkdir -p "sdk/dotnet/native/runtimes/${{ matrix.rid }}/native"
cp "target/${{ matrix.target }}/release/${{ matrix.lib }}" \
"sdk/dotnet/native/runtimes/${{ matrix.rid }}/native/${{ matrix.lib }}"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dotnet-native-${{ matrix.rid }}
path: sdk/dotnet/native/runtimes/${{ matrix.rid }}/native/${{ matrix.lib }}

dotnet:
needs: dotnet-native
runs-on: ubuntu-latest
environment: release
permissions:
Expand All @@ -237,11 +273,34 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with: { dotnet-version: "8.0.x" }
- name: Collect the native libraries
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
pattern: dotnet-native-*
path: sdk/dotnet/native/staging
- name: Lay them out by runtime identifier
shell: bash
run: |
set -euo pipefail
for dir in sdk/dotnet/native/staging/dotnet-native-*; do
rid="${dir##*/dotnet-native-}"
mkdir -p "sdk/dotnet/native/runtimes/$rid/native"
cp "$dir"/* "sdk/dotnet/native/runtimes/$rid/native/"
done
find sdk/dotnet/native/runtimes -type f | sort
- name: Pack
run: |
dotnet restore --nologo
dotnet pack src/AgentControlSpec -c Release -o dist --nologo
dotnet pack src/AgentControlSpec -c Release -o dist --nologo -p:AcsNativeAssetsRequired=true
working-directory: sdk/dotnet
- name: Prove the packed artifact runs without a local engine build
shell: bash
run: |
set -euo pipefail
nupkg=$(find sdk/dotnet/dist -name 'ResponsibleAI.AgentControlSpec.*.nupkg' -print -quit)
version=$(basename "$nupkg" .nupkg)
version=${version#ResponsibleAI.AgentControlSpec.}
bash tests/conformance/bindings/dotnet_package.sh "$PWD/sdk/dotnet/dist" "$version"
- name: SBOM
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0
with:
Expand Down
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,12 @@ __pycache__/
dist/
bin/
obj/

# Per-RID engine binaries staged for the .NET package (built, never committed).
/sdk/dotnet/native/

# napi writes these next to the committed binding.js loader when the
# platform build runs in place. The loader the package ships is
# binding.js; these are byproducts.
/sdk/node/index.js
/sdk/node/index.d.ts
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ those are host obligations defined by agent-hooks.
| Path | Contents |
| --- | --- |
| `engine/` | Rust evaluation core (`agent-control-spec` crate): manifest, dispatchers, annotators, policy-output normalization, the `AcsInterceptor` |
| `sdk/ffi/` | C ABI over the engine (`agent-control-spec-ffi`), which the .NET binding calls |
| `sdk/python/` | Python binding: `agent_control_spec` package wrapping the engine as an `agent_hooks` interceptor |
| `sdk/node/` | Node binding: `@responsibleai/agent-control-spec` |
| `sdk/dotnet/` | .NET binding: `ResponsibleAI.AgentControlSpec` |
| `spec/` | The ACS specification (policy plane) and schemas |
| `policy/` | Cedar and Rego policy libraries |
| `fixtures/` | Evaluation fixtures |
Expand Down
1 change: 1 addition & 0 deletions engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub mod runtime;
pub mod stream_session;
pub mod telemetry;
pub mod tool_projection;
pub mod wire;

// The interception contract, re-exported for consumers that want a
// single dependency.
Expand Down
39 changes: 39 additions & 0 deletions engine/src/stream_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,19 @@ pub enum StreamTrack {
}

impl StreamTrack {
/// Parse a track's wire name.
///
/// Every binding needs this and none should own it. Three copies of
/// what `"response"` means are three chances to disagree, which is
/// the drift this module exists to prevent.
pub fn parse(value: &str) -> Result<Self, StreamError> {
match value {
"request" => Ok(Self::Request),
"response" => Ok(Self::Response),
_ => Err(StreamError::UnknownStreamTrack(value.to_string())),
}
}

pub fn as_str(self) -> &'static str {
match self {
Self::Request => "request",
Expand All @@ -223,6 +236,8 @@ impl StreamTrack {
pub enum StreamError {
UnknownSafetyLevel(String),
UnknownSourceType(String),
UnknownStreamTrack(String),
UnknownSegmentOutcome(String),
/// Payload arrived on a track the session does not mediate.
///
/// An empty task set means that track is not mediated, which is the
Expand Down Expand Up @@ -302,6 +317,10 @@ impl fmt::Display for StreamError {
write!(f, "unknown streaming safety level {value}")
}
Self::UnknownSourceType(value) => write!(f, "unknown stream source type {value}"),
Self::UnknownStreamTrack(value) => write!(f, "unknown stream track {value}"),
Self::UnknownSegmentOutcome(value) => {
write!(f, "unknown segment outcome {value}")
}
Self::NoTasks(track) => write!(
f,
"payload arrived on the unmediated {} track",
Expand Down Expand Up @@ -421,6 +440,26 @@ pub enum SegmentOutcome {
Denied,
}

impl SegmentOutcome {
/// Parse an outcome's wire name.
pub fn parse(value: &str) -> Result<Self, StreamError> {
match value {
"cleared" => Ok(Self::Cleared),
"transformed" => Ok(Self::Transformed),
"denied" => Ok(Self::Denied),
_ => Err(StreamError::UnknownSegmentOutcome(value.to_string())),
}
}

pub fn as_str(self) -> &'static str {
match self {
Self::Cleared => "cleared",
Self::Transformed => "transformed",
Self::Denied => "denied",
}
}
}

/// Reason a session reached its terminal state.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StreamEndReason {
Expand Down
Loading
Loading