Add Ed agent tool calling
Co-Authored-By: siGit Code <noreply@sigit.si>
Seto Elkahfi committed
Sep 9, 2026 at 23:11 UTC
6d39dfdf00636e7fbb452fb4ff07fbf4fa048c92
26 files changed
+2268
-36
.cargo/config.toml
new
+14
@@ -0,0 +1,14 @@
1
+[target.aarch64-apple-ios]
2
+rustflags = ["-C", "target-feature=+fp16"]
3
+
4
+[target.aarch64-apple-ios-sim]
5
+rustflags = ["-C", "target-feature=+fp16"]
6
+
7
+[target.aarch64-apple-tvos]
8
+rustflags = ["-C", "target-feature=+fp16"]
9
+
10
+[target.aarch64-apple-tvos-sim]
11
+rustflags = ["-C", "target-feature=+fp16"]
12
+
13
+[target.x86_64-apple-tvos]
14
+rustflags = ["-C", "target-feature=+fp16"]
.github/scripts/build-swift-xcframework.sh
new
+76
@@ -0,0 +1,76 @@
1
+#!/usr/bin/env bash
2
+set -euo pipefail
3
+
4
+ROOT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
5
+cd "$ROOT_DIR"
6
+
7
+DIST_DIR="$ROOT_DIR/dist/swift"
8
+PACKAGE_DIR="$DIST_DIR/Package"
9
+HEADERS_DIR="$DIST_DIR/Headers"
10
+FRAMEWORK_DIR="$DIST_DIR/EdFramework.xcframework"
11
+ZIP_PATH="$DIST_DIR/EdFramework.xcframework.zip"
12
+CHECKSUM_PATH="$DIST_DIR/EdFramework.checksum.txt"
13
+VERSION_PATH="$DIST_DIR/version.txt"
14
+BINDGEN="$ROOT_DIR/target/release/uniffi-bindgen"
15
+MANIFEST="$ROOT_DIR/crates/ed-agent-ffi/Cargo.toml"
16
+APPLE_TARGET_DIR="$ROOT_DIR/target/apple"
17
+
18
+IOS_DEPLOYMENT_TARGET="${IOS_DEPLOYMENT_TARGET:-16.0}"
19
+MACOS_DEPLOYMENT_TARGET="${MACOS_DEPLOYMENT_TARGET:-14.0}"
20
+TVOS_DEPLOYMENT_TARGET="${TVOS_DEPLOYMENT_TARGET:-16.0}"
21
+VISIONOS_DEPLOYMENT_TARGET="${VISIONOS_DEPLOYMENT_TARGET:-1.0}"
22
+WATCHOS_DEPLOYMENT_TARGET="${WATCHOS_DEPLOYMENT_TARGET:-9.0}"
23
+
24
+rm -rf "$FRAMEWORK_DIR" "$ZIP_PATH" "$CHECKSUM_PATH" "$VERSION_PATH" "$PACKAGE_DIR" "$HEADERS_DIR"
25
+mkdir -p "$PACKAGE_DIR/Sources/Ed" "$HEADERS_DIR"
26
+
27
+cargo build --manifest-path uniffi-bindgen/Cargo.toml --release
28
+
29
+IPHONEOS_DEPLOYMENT_TARGET="$IOS_DEPLOYMENT_TARGET" cargo rustc --target-dir "$APPLE_TARGET_DIR" --manifest-path "$MANIFEST" --target aarch64-apple-ios --release --lib --crate-type staticlib
30
+IPHONEOS_DEPLOYMENT_TARGET="$IOS_DEPLOYMENT_TARGET" cargo rustc --target-dir "$APPLE_TARGET_DIR" --manifest-path "$MANIFEST" --target aarch64-apple-ios-sim --release --lib --crate-type staticlib
31
+MACOSX_DEPLOYMENT_TARGET="$MACOS_DEPLOYMENT_TARGET" cargo rustc --target-dir "$APPLE_TARGET_DIR" --manifest-path "$MANIFEST" --target aarch64-apple-darwin --release --lib --crate-type staticlib
32
+TVOS_DEPLOYMENT_TARGET="$TVOS_DEPLOYMENT_TARGET" cargo +nightly rustc -Z build-std --target-dir "$APPLE_TARGET_DIR" --manifest-path "$MANIFEST" --target aarch64-apple-tvos --release --lib --crate-type staticlib
33
+TVOS_DEPLOYMENT_TARGET="$TVOS_DEPLOYMENT_TARGET" cargo +nightly rustc -Z build-std --target-dir "$APPLE_TARGET_DIR" --manifest-path "$MANIFEST" --target aarch64-apple-tvos-sim --release --lib --crate-type staticlib
34
+XROS_DEPLOYMENT_TARGET="$VISIONOS_DEPLOYMENT_TARGET" cargo +nightly rustc -Z build-std --target-dir "$APPLE_TARGET_DIR" --manifest-path "$MANIFEST" --target aarch64-apple-visionos --release --lib --crate-type staticlib
35
+XROS_DEPLOYMENT_TARGET="$VISIONOS_DEPLOYMENT_TARGET" cargo +nightly rustc -Z build-std --target-dir "$APPLE_TARGET_DIR" --manifest-path "$MANIFEST" --target aarch64-apple-visionos-sim --release --lib --crate-type staticlib
36
+WATCHOS_DEPLOYMENT_TARGET="$WATCHOS_DEPLOYMENT_TARGET" cargo +nightly rustc -Z build-std --target-dir "$APPLE_TARGET_DIR" --manifest-path "$MANIFEST" --target aarch64-apple-watchos --release --lib --crate-type staticlib
37
+WATCHOS_DEPLOYMENT_TARGET="$WATCHOS_DEPLOYMENT_TARGET" cargo +nightly rustc -Z build-std --target-dir "$APPLE_TARGET_DIR" --manifest-path "$MANIFEST" --target aarch64-apple-watchos-sim --release --lib --crate-type staticlib
38
+
39
+"$BINDGEN" generate "$APPLE_TARGET_DIR/aarch64-apple-ios/release/libed_agent_ffi.a" --crate ed_agent_ffi --language swift --out-dir "$PACKAGE_DIR/Sources/Ed"
40
+cp "$PACKAGE_DIR/Sources/Ed/ed_agent_ffiFFI.h" "$HEADERS_DIR/ed_agent_ffiFFI.h"
41
+cp "$PACKAGE_DIR/Sources/Ed/ed_agent_ffiFFI.modulemap" "$HEADERS_DIR/module.modulemap"
42
+
43
+xcodebuild -create-xcframework \
44
+ -library "$APPLE_TARGET_DIR/aarch64-apple-ios/release/libed_agent_ffi.a" -headers "$HEADERS_DIR" \
45
+ -library "$APPLE_TARGET_DIR/aarch64-apple-ios-sim/release/libed_agent_ffi.a" -headers "$HEADERS_DIR" \
46
+ -library "$APPLE_TARGET_DIR/aarch64-apple-tvos/release/libed_agent_ffi.a" -headers "$HEADERS_DIR" \
47
+ -library "$APPLE_TARGET_DIR/aarch64-apple-tvos-sim/release/libed_agent_ffi.a" -headers "$HEADERS_DIR" \
48
+ -library "$APPLE_TARGET_DIR/aarch64-apple-visionos/release/libed_agent_ffi.a" -headers "$HEADERS_DIR" \
49
+ -library "$APPLE_TARGET_DIR/aarch64-apple-visionos-sim/release/libed_agent_ffi.a" -headers "$HEADERS_DIR" \
50
+ -library "$APPLE_TARGET_DIR/aarch64-apple-watchos/release/libed_agent_ffi.a" -headers "$HEADERS_DIR" \
51
+ -library "$APPLE_TARGET_DIR/aarch64-apple-watchos-sim/release/libed_agent_ffi.a" -headers "$HEADERS_DIR" \
52
+ -library "$APPLE_TARGET_DIR/aarch64-apple-darwin/release/libed_agent_ffi.a" -headers "$HEADERS_DIR" \
53
+ -output "$FRAMEWORK_DIR"
54
+
55
+export FRAMEWORK_DIR ZIP_PATH
56
+python3 - <<'PY'
57
+import os, stat, zipfile
58
+from pathlib import Path
59
+
60
+root = Path(os.environ["FRAMEWORK_DIR"])
61
+output = Path(os.environ["ZIP_PATH"])
62
+with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
63
+ for path in sorted(item for item in root.rglob("*") if item.is_file()):
64
+ info = zipfile.ZipInfo(str(path.relative_to(root.parent)), (1980, 1, 1, 0, 0, 0))
65
+ info.compress_type = zipfile.ZIP_DEFLATED
66
+ info.external_attr = (stat.S_IFREG | 0o644) << 16
67
+ archive.writestr(info, path.read_bytes())
68
+PY
69
+
70
+swift package compute-checksum "$ZIP_PATH" > "$CHECKSUM_PATH"
71
+cargo metadata --no-deps --format-version 1 \
72
+ | python3 -c 'import json,sys; print(next(p["version"] for p in json.load(sys.stdin)["packages"] if p["name"] == "ed-agent"))' \
73
+ > "$VERSION_PATH"
74
+
75
+printf 'XCFramework: %s\nChecksum: %s\nVersion: %s\n' \
76
+ "$FRAMEWORK_DIR" "$(cat "$CHECKSUM_PATH")" "$(cat "$VERSION_PATH")"
.github/workflows/release-sdk-swift.yml
new
+142
@@ -0,0 +1,142 @@
1
+name: Release SDK Swift
2
+
3
+on:
4
+ push:
5
+ tags:
6
+ - "[0-9]+.[0-9]+.[0-9]+"
7
+ workflow_dispatch:
8
+ inputs:
9
+ publish:
10
+ description: "Publish Ed Swift release assets from the selected ref"
11
+ type: boolean
12
+ default: false
13
+ release_tag:
14
+ description: "Existing semver tag to publish on manual runs"
15
+ type: string
16
+ required: false
17
+
18
+permissions:
19
+ contents: write
20
+
21
+jobs:
22
+ preflight:
23
+ runs-on: ubuntu-latest
24
+ steps:
25
+ - uses: actions/checkout@v6
26
+ - name: Read Rust toolchain
27
+ run: |
28
+ rust_toolchain="$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml | head -n 1)"
29
+ test -n "$rust_toolchain"
30
+ echo "RUST_TOOLCHAIN=$rust_toolchain" >> "$GITHUB_ENV"
31
+ - uses: dtolnay/rust-toolchain@stable
32
+ with:
33
+ toolchain: ${{ env.RUST_TOOLCHAIN }}
34
+ - name: Validate tag matches workspace version
35
+ if: startsWith(github.ref, 'refs/tags/')
36
+ run: |
37
+ version="$(cargo metadata --no-deps --format-version 1 | python3 -c 'import json,sys; print(next(p["version"] for p in json.load(sys.stdin)["packages"] if p["name"] == "ed-agent"))')"
38
+ test "$GITHUB_REF_NAME" = "$version"
39
+ - run: cargo fmt --all -- --check
40
+ - run: cargo test --locked --workspace
41
+ - run: cargo clippy --locked --workspace --all-targets -- -D warnings
42
+ - run: cargo publish --dry-run --locked -p ed-agent
43
+
44
+ build-and-release:
45
+ needs: preflight
46
+ runs-on: macos-26
47
+ steps:
48
+ - uses: actions/checkout@v6
49
+ - name: Read Rust toolchain
50
+ run: |
51
+ rust_toolchain="$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml | head -n 1)"
52
+ test -n "$rust_toolchain"
53
+ echo "RUST_TOOLCHAIN=$rust_toolchain" >> "$GITHUB_ENV"
54
+ - uses: dtolnay/rust-toolchain@stable
55
+ with:
56
+ toolchain: ${{ env.RUST_TOOLCHAIN }}
57
+ targets: aarch64-apple-ios,aarch64-apple-ios-sim,aarch64-apple-darwin
58
+ - uses: dtolnay/rust-toolchain@nightly
59
+ with:
60
+ components: rust-src
61
+ - run: .github/scripts/build-swift-xcframework.sh
62
+ - id: meta
63
+ run: |
64
+ echo "version=$(cat dist/swift/version.txt)" >> "$GITHUB_OUTPUT"
65
+ echo "checksum=$(cat dist/swift/EdFramework.checksum.txt)" >> "$GITHUB_OUTPUT"
66
+ - id: release
67
+ env:
68
+ INPUT_RELEASE_TAG: ${{ inputs.release_tag }}
69
+ run: |
70
+ if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
71
+ release_tag="${GITHUB_REF_NAME}"
72
+ else
73
+ release_tag="${INPUT_RELEASE_TAG}"
74
+ fi
75
+ echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT"
76
+ - name: Validate release tag
77
+ if: startsWith(github.ref, 'refs/tags/') || inputs.publish
78
+ env:
79
+ VERSION: ${{ steps.meta.outputs.version }}
80
+ RELEASE_TAG: ${{ steps.release.outputs.release_tag }}
81
+ run: test -n "$RELEASE_TAG" && test "$RELEASE_TAG" = "$VERSION"
82
+ - name: Upload build artifact
83
+ if: github.event_name == 'workflow_dispatch'
84
+ uses: actions/upload-artifact@v7
85
+ with:
86
+ name: ed-swift-xcframework
87
+ path: |
88
+ dist/swift/EdFramework.xcframework.zip
89
+ dist/swift/EdFramework.checksum.txt
90
+ dist/swift/version.txt
91
+ dist/swift/Package/Sources/Ed/ed_agent_ffi.swift
92
+ - name: Create GitHub release
93
+ if: startsWith(github.ref, 'refs/tags/') || inputs.publish
94
+ uses: softprops/action-gh-release@v3
95
+ with:
96
+ tag_name: ${{ steps.release.outputs.release_tag }}
97
+ name: ${{ steps.release.outputs.release_tag }}
98
+ generate_release_notes: true
99
+ files: |
100
+ dist/swift/EdFramework.xcframework.zip
101
+ dist/swift/EdFramework.checksum.txt
102
+ - name: Checkout onde-ed-swift
103
+ if: startsWith(github.ref, 'refs/tags/') || inputs.publish
104
+ uses: actions/checkout@v6
105
+ with:
106
+ repository: ondeinference/onde-ed-swift
107
+ token: ${{ secrets.ED_SWIFT_PAT }}
108
+ path: onde-ed-swift
109
+ - name: Update Swift package
110
+ if: startsWith(github.ref, 'refs/tags/') || inputs.publish
111
+ env:
112
+ CHECKSUM: ${{ steps.meta.outputs.checksum }}
113
+ RELEASE_URL: https://github.com/${{ github.repository }}/releases/download/${{ steps.release.outputs.release_tag }}/EdFramework.xcframework.zip
114
+ run: |
115
+ python3 - <<'PY'
116
+ import os, pathlib, re
117
+ package = pathlib.Path("onde-ed-swift/Package.swift")
118
+ source = package.read_text()
119
+ source, urls = re.subn(r'(let releaseFrameworkURL =\s*\n\s*")([^"]+)(")', rf'\g<1>{os.environ["RELEASE_URL"]}\g<3>', source, count=1)
120
+ source, sums = re.subn(r'(let releaseFrameworkChecksum =\s*\n\s*")([^"]+)(")', rf'\g<1>{os.environ["CHECKSUM"]}\g<3>', source, count=1)
121
+ if (urls, sums) != (1, 1):
122
+ raise RuntimeError("Package.swift release constants were not found")
123
+ package.write_text(source)
124
+ PY
125
+ cp dist/swift/Package/Sources/Ed/ed_agent_ffi.swift onde-ed-swift/Sources/Ed/ed_agent_ffi.swift
126
+ - name: Commit and tag Swift package
127
+ if: startsWith(github.ref, 'refs/tags/') || inputs.publish
128
+ env:
129
+ VERSION: ${{ steps.release.outputs.release_tag }}
130
+ run: |
131
+ cd onde-ed-swift
132
+ git config user.name "github-actions[bot]"
133
+ git config user.email "github-actions[bot]@users.noreply.github.com"
134
+ git add Package.swift Sources/Ed/ed_agent_ffi.swift
135
+ git diff --cached --quiet || git commit -m "Release $VERSION"
136
+ git push origin HEAD:main
137
+ if git ls-remote --tags origin "refs/tags/$VERSION" | grep -q "refs/tags/$VERSION"; then
138
+ echo "::warning::Tag $VERSION already exists; leaving it unchanged."
139
+ else
140
+ git tag -a "$VERSION" -m "Release $VERSION"
141
+ git push origin "$VERSION"
142
+ fi
.gitignore
+1
@@ -1,4 +1,5 @@
1
/target
2
+/dist
3
**/*.rs.bk
4
Cargo.lock.bak
5
.DS_Store
Cargo.lock
+28
-4
@@ -1,6 +1,6 @@
1
# This file is automatically @generated by Cargo.
2
# It is not intended for manual editing.
3
-version = 3
3
+version = 4
4
5
[[package]]
6
name = "Inflector"
@@ -1688,12 +1688,29 @@ checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9"
1688
name = "ed-agent"
1689
version = "0.1.0"
1690
dependencies = [
1691
+ "async-trait",
1692
"log",
1693
"onde",
1694
"serde",
1695
+ "serde_json",
1696
+ "thiserror 2.0.20",
1697
"tokio",
1698
]
1699
1700
+[[package]]
1701
+name = "ed-agent-ffi"
1702
+version = "0.1.0"
1703
+dependencies = [
1704
+ "async-trait",
1705
+ "cc",
1706
+ "ed-agent",
1707
+ "onde",
1708
+ "serde_json",
1709
+ "smbcloud-gresiq-sdk",
1710
+ "thiserror 2.0.20",
1711
+ "uniffi",
1712
+]
1713
+
1714
[[package]]
1715
name = "ed-agent-tauri"
1716
version = "0.1.0"
@@ -1704,6 +1721,13 @@ dependencies = [
1721
"tauri",
1722
]
1723
1724
+[[package]]
1725
+name = "ed-uniffi-bindgen"
1726
+version = "0.1.0"
1727
+dependencies = [
1728
+ "uniffi",
1729
+]
1730
+
1731
[[package]]
1732
name = "ego-tree"
1733
version = "0.10.0"
@@ -4498,9 +4522,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
4522
4523
[[package]]
4524
name = "onde"
4501
-version = "1.2.2"
4502
-source = "registry+https://github.com/rust-lang/crates.io-index"
4503
-checksum = "8d84f62e53e6ce8efce62baac8e769d08f83ff19ed6cd9d1a91eeb769fedd618"
4525
+version = "1.2.5"
4526
dependencies = [
4527
"anyhow",
4528
"cc",
@@ -8295,7 +8317,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
8317
checksum = "b8c6dec3fc6645f71a16a3fa9ff57991028153bd194ca97f4b55e610c73ce66a"
8318
dependencies = [
8319
"anyhow",
8320
+ "camino",
8321
"cargo_metadata",
8322
+ "clap 4.6.6",
8323
"uniffi_bindgen",
8324
"uniffi_build",
8325
"uniffi_core",
Cargo.toml
+13
-8
@@ -1,27 +1,32 @@
1
[workspace]
2
resolver = "2"
3
-members = ["crates/ed-agent", "crates/ed-agent-tauri"]
3
+members = ["crates/ed-agent", "crates/ed-agent-ffi", "crates/ed-agent-tauri", "uniffi-bindgen"]
4
5
# Shared across every crate in the workspace so a release moves one number.
6
# `sync-version` tooling (see the Makefile) reads these.
7
[workspace.package]
8
version = "0.1.0"
9
edition = "2021"
10
-rust-version = "1.82"
11
-license = "Apache-2.0"
10
+rust-version = "1.94"
11
+license = "MIT OR Apache-2.0"
12
authors = ["Splitfire AB"]
13
-repository = "https://github.com/ondeinference/ed"
13
+repository = "https://github.com/ondeinference/onde-ed"
14
homepage = "https://ondeinference.com"
15
16
[workspace.dependencies]
17
-# The inference engine Ed wraps. Pinned to a published release: Ed itself is
18
-# published, so a git or path dependency here would break `cargo publish`.
19
-onde = "1.2.2"
17
+# The inference engine Ed wraps. Cargo uses the sibling checkout locally and
18
+# publishes only the version requirement. Release Onde's tool-calling API
19
+# before publishing Ed.
20
+onde = { version = "1.2.5", path = "../onde" }
21
+async-trait = "0.1"
22
serde = { version = "1", features = ["derive"] }
23
serde_json = "1"
24
log = "0.4"
23
-tokio = { version = "1", features = ["sync"] }
25
+thiserror = "2"
26
+tokio = { version = "1", features = ["sync", "rt"] }
27
tauri = { version = "2", features = [] }
28
+uniffi = { version = "=0.31.0", features = ["tokio"] }
29
+smbcloud-gresiq-sdk = "0.4.7"
30
31
# Internal
32
ed-agent = { version = "0.1.0", path = "crates/ed-agent" }
LICENSE-APACHE
new
+202
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright 2026 Splitfire AB (Onde Inference)
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
LICENSE-MIT
new
+21
@@ -0,0 +1,21 @@
1
+MIT License
2
+
3
+Copyright (c) 2026 Splitfire AB (Onde Inference)
4
+
5
+Permission is hereby granted, free of charge, to any person obtaining a copy
6
+of this software and associated documentation files (the "Software"), to deal
7
+in the Software without restriction, including without limitation the rights
8
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+copies of the Software, and to permit persons to whom the Software is
10
+furnished to do so, subject to the following conditions:
11
+
12
+The above copyright notice and this permission notice shall be included in all
13
+copies or substantial portions of the Software.
14
+
15
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+SOFTWARE.
README.md
+9
-7
@@ -2,10 +2,10 @@
2
3
**Embed Ed.** Your app gets a brain. Your users keep their data.
4
5
-Ed is a chat agent you drop into an application. It loads an open-weights model,
6
-tells your interface when that model is ready, keeps the conversation, and hands
7
-back replies. The model runs on the user's device — there is no API key, no
8
-metered token, no round trip, and nothing to leak.
5
+Ed is an agent you drop into an application. It loads an open-weights model,
6
+keeps the conversation, calls only the host tools you register, and hands back
7
+replies. The model runs on the user's device — there is no metered inference
8
+API and private context does not need to leave the device.
9
10
## Why it exists
11
@@ -57,14 +57,16 @@ let reply = ed.send("Summarise this thread.").await;
57
println!("{}", reply.reply.unwrap_or_default());
58
```
59
60
-That's a working agent. To hear about state changes rather than polling for
61
-them, implement `StatusSink` and build with `Ed::with_sink`.
60
+Construct with `Ed::with_agent` to provide the executor for registered tools
61
+and the approval handler for mutating tools. Read-only tools run directly;
62
+mutating tools must receive `AllowOnce` or `AllowForSession` first.
63
64
## Crates
65
66
| Crate | What it's for |
67
| --- | --- |
68
| [`ed-agent`](crates/ed-agent) | The agent. No framework dependency. |
69
+| [`ed-agent-ffi`](crates/ed-agent-ffi) | UniFFI bridge used by the Swift XCFramework. |
70
| [`ed-agent-tauri`](crates/ed-agent-tauri) | Tauri bindings: a sink that emits to the webview, plus the commands it invokes. |
71
72
Free the intelligence.
@@ -72,4 +74,4 @@ Free the intelligence.
74
## Copyright
75
76
© 2026 [Splitfire AB](https://5mb.app) ([Onde Inference](https://ondeinference.com)).
75
-Licensed under Apache-2.0.
77
+Licensed under MIT or Apache-2.0.
crates/ed-agent-ffi/Cargo.toml
new
+28
@@ -0,0 +1,28 @@
1
+[package]
2
+name = "ed-agent-ffi"
3
+description = "UniFFI bridge used to build Ed's Apple XCFramework."
4
+publish = false
5
+version.workspace = true
6
+edition.workspace = true
7
+rust-version.workspace = true
8
+license.workspace = true
9
+authors.workspace = true
10
+repository.workspace = true
11
+homepage.workspace = true
12
+
13
+[lib]
14
+name = "ed_agent_ffi"
15
+crate-type = ["lib", "cdylib", "staticlib"]
16
+
17
+[dependencies]
18
+ed-agent.workspace = true
19
+onde.workspace = true
20
+async-trait.workspace = true
21
+serde_json.workspace = true
22
+thiserror.workspace = true
23
+uniffi.workspace = true
24
+smbcloud-gresiq-sdk.workspace = true
25
+
26
+[build-dependencies]
27
+cc = "1"
28
+uniffi = { version = "=0.31.0", features = ["build"] }
crates/ed-agent-ffi/build.rs
new
+9
@@ -0,0 +1,9 @@
1
+fn main() {
2
+ let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
3
+ if matches!(target_os.as_str(), "ios" | "tvos" | "visionos" | "watchos") {
4
+ cc::Build::new()
5
+ .file("scripts/tvos_chkstk.s")
6
+ .compile("tvos_chkstk");
7
+ println!("cargo:rerun-if-changed=scripts/tvos_chkstk.s");
8
+ }
9
+}
crates/ed-agent-ffi/scripts/tvos_chkstk.s
new
+6
@@ -0,0 +1,6 @@
1
+.text
2
+.globl ___chkstk_darwin
3
+.p2align 2
4
+
5
+___chkstk_darwin:
6
+ ret
crates/ed-agent-ffi/src/lib.rs
new
+584
@@ -0,0 +1,584 @@
1
+//! UniFFI surface for Ed's Swift package.
2
+
3
+use std::sync::Arc;
4
+
5
+use async_trait::async_trait;
6
+use ed_agent::{
7
+ AgentConfig, AgentError, AgentReply, AgentToolDefinition, ApprovalDecision, ApprovalHandler,
8
+ ApprovalRequest, ChatMessage, Ed, EngineInfo, EngineStatus, EventSink, GgufModelConfig,
9
+ SamplingConfig, ToolCall, ToolExecutionResult, ToolExecutor, ToolRisk, UqffModelConfig,
10
+};
11
+
12
+#[derive(Debug, Clone, uniffi::Record)]
13
+pub struct FfiAgentConfig {
14
+ pub max_tool_rounds: u8,
15
+ pub max_tool_output_chars: u64,
16
+}
17
+
18
+impl Default for FfiAgentConfig {
19
+ fn default() -> Self {
20
+ let config = AgentConfig::default();
21
+ Self {
22
+ max_tool_rounds: config.max_tool_rounds,
23
+ max_tool_output_chars: config.max_tool_output_chars as u64,
24
+ }
25
+ }
26
+}
27
+
28
+#[derive(Debug, Clone, Copy, uniffi::Enum)]
29
+pub enum FfiToolRisk {
30
+ ReadOnly,
31
+ Mutating,
32
+}
33
+
34
+impl From<FfiToolRisk> for ToolRisk {
35
+ fn from(value: FfiToolRisk) -> Self {
36
+ match value {
37
+ FfiToolRisk::ReadOnly => Self::ReadOnly,
38
+ FfiToolRisk::Mutating => Self::Mutating,
39
+ }
40
+ }
41
+}
42
+
43
+#[derive(Debug, Clone, uniffi::Record)]
44
+pub struct FfiToolDefinition {
45
+ pub name: String,
46
+ pub description: String,
47
+ pub parameters_schema: String,
48
+ pub risk: FfiToolRisk,
49
+}
50
+
51
+#[derive(Debug, Clone, uniffi::Record)]
52
+pub struct FfiToolCall {
53
+ pub id: String,
54
+ pub name: String,
55
+ pub arguments: String,
56
+}
57
+
58
+impl From<&ToolCall> for FfiToolCall {
59
+ fn from(value: &ToolCall) -> Self {
60
+ Self {
61
+ id: value.id.clone(),
62
+ name: value.name.clone(),
63
+ arguments: value.arguments.clone(),
64
+ }
65
+ }
66
+}
67
+
68
+#[derive(Debug, Clone, uniffi::Record)]
69
+pub struct FfiToolOutput {
70
+ pub content: String,
71
+ pub error: Option<String>,
72
+}
73
+
74
+#[derive(Debug, Clone, Copy, uniffi::Enum)]
75
+pub enum FfiApprovalDecision {
76
+ AllowOnce,
77
+ AllowForSession,
78
+ Deny,
79
+}
80
+
81
+impl From<FfiApprovalDecision> for ApprovalDecision {
82
+ fn from(value: FfiApprovalDecision) -> Self {
83
+ match value {
84
+ FfiApprovalDecision::AllowOnce => Self::AllowOnce,
85
+ FfiApprovalDecision::AllowForSession => Self::AllowForSession,
86
+ FfiApprovalDecision::Deny => Self::Deny,
87
+ }
88
+ }
89
+}
90
+
91
+#[derive(Debug, Clone, Copy, uniffi::Enum)]
92
+pub enum FfiEngineStatus {
93
+ Unloaded,
94
+ Loading,
95
+ Ready,
96
+ Generating,
97
+ Error,
98
+}
99
+
100
+impl From<EngineStatus> for FfiEngineStatus {
101
+ fn from(value: EngineStatus) -> Self {
102
+ match value {
103
+ EngineStatus::Unloaded => Self::Unloaded,
104
+ EngineStatus::Loading => Self::Loading,
105
+ EngineStatus::Ready => Self::Ready,
106
+ EngineStatus::Generating => Self::Generating,
107
+ EngineStatus::Error => Self::Error,
108
+ }
109
+ }
110
+}
111
+
112
+#[derive(Debug, Clone, uniffi::Record)]
113
+pub struct FfiStatusUpdate {
114
+ pub status: FfiEngineStatus,
115
+ pub model_name: Option<String>,
116
+ pub error: Option<String>,
117
+}
118
+
119
+#[derive(Debug, Clone, uniffi::Record)]
120
+pub struct FfiAgentReply {
121
+ pub text: String,
122
+ pub duration_seconds: f64,
123
+ pub duration: String,
124
+ pub tool_rounds: u8,
125
+}
126
+
127
+impl From<&AgentReply> for FfiAgentReply {
128
+ fn from(value: &AgentReply) -> Self {
129
+ Self {
130
+ text: value.text.clone(),
131
+ duration_seconds: value.duration_seconds,
132
+ duration: value.duration.clone(),
133
+ tool_rounds: value.tool_rounds,
134
+ }
135
+ }
136
+}
137
+
138
+#[derive(Debug, Clone, uniffi::Record)]
139
+pub struct FfiReply {
140
+ pub text: String,
141
+ pub duration: String,
142
+}
143
+
144
+#[derive(Debug, Clone, uniffi::Record)]
145
+pub struct FfiEngineInfo {
146
+ pub status: FfiEngineStatus,
147
+ pub model_name: Option<String>,
148
+ pub approx_memory: Option<String>,
149
+ pub history_length: u64,
150
+}
151
+
152
+impl From<EngineInfo> for FfiEngineInfo {
153
+ fn from(value: EngineInfo) -> Self {
154
+ Self {
155
+ status: value.status.into(),
156
+ model_name: value.model_name,
157
+ approx_memory: value.approx_memory,
158
+ history_length: value.history_length,
159
+ }
160
+ }
161
+}
162
+
163
+#[derive(Debug, Clone, Copy, uniffi::Enum)]
164
+pub enum FfiChatRole {
165
+ System,
166
+ User,
167
+ Assistant,
168
+}
169
+
170
+#[derive(Debug, Clone, uniffi::Record)]
171
+pub struct FfiChatMessage {
172
+ pub role: FfiChatRole,
173
+ pub content: String,
174
+}
175
+
176
+impl From<ChatMessage> for FfiChatMessage {
177
+ fn from(value: ChatMessage) -> Self {
178
+ let role = match value.role {
179
+ ed_agent::ChatRole::System => FfiChatRole::System,
180
+ ed_agent::ChatRole::User => FfiChatRole::User,
181
+ ed_agent::ChatRole::Assistant => FfiChatRole::Assistant,
182
+ };
183
+ Self {
184
+ role,
185
+ content: value.content,
186
+ }
187
+ }
188
+}
189
+
190
+#[derive(Debug, Clone, uniffi::Record)]
191
+pub struct FfiSamplingConfig {
192
+ pub temperature: Option<f64>,
193
+ pub top_p: Option<f64>,
194
+ pub top_k: Option<u64>,
195
+ pub min_p: Option<f64>,
196
+ pub max_tokens: Option<u64>,
197
+ pub frequency_penalty: Option<f32>,
198
+ pub presence_penalty: Option<f32>,
199
+}
200
+
201
+impl From<FfiSamplingConfig> for SamplingConfig {
202
+ fn from(value: FfiSamplingConfig) -> Self {
203
+ Self {
204
+ temperature: value.temperature,
205
+ top_p: value.top_p,
206
+ top_k: value.top_k,
207
+ min_p: value.min_p,
208
+ max_tokens: value.max_tokens,
209
+ frequency_penalty: value.frequency_penalty,
210
+ presence_penalty: value.presence_penalty,
211
+ }
212
+ }
213
+}
214
+
215
+#[derive(Debug, Clone, uniffi::Record)]
216
+pub struct FfiGgufModelConfig {
217
+ pub model_id: String,
218
+ pub files: Vec<String>,
219
+ pub tok_model_id: Option<String>,
220
+ pub display_name: String,
221
+ pub approx_memory: String,
222
+ pub chat_template: Option<String>,
223
+}
224
+
225
+impl From<FfiGgufModelConfig> for GgufModelConfig {
226
+ fn from(value: FfiGgufModelConfig) -> Self {
227
+ Self {
228
+ model_id: value.model_id,
229
+ files: value.files,
230
+ tok_model_id: value.tok_model_id,
231
+ display_name: value.display_name,
232
+ approx_memory: value.approx_memory,
233
+ chat_template: value.chat_template,
234
+ }
235
+ }
236
+}
237
+
238
+impl From<GgufModelConfig> for FfiGgufModelConfig {
239
+ fn from(value: GgufModelConfig) -> Self {
240
+ Self {
241
+ model_id: value.model_id,
242
+ files: value.files,
243
+ tok_model_id: value.tok_model_id,
244
+ display_name: value.display_name,
245
+ approx_memory: value.approx_memory,
246
+ chat_template: value.chat_template,
247
+ }
248
+ }
249
+}
250
+
251
+#[derive(Debug, Clone, uniffi::Record)]
252
+pub struct FfiUqffModelConfig {
253
+ pub model_id: String,
254
+ pub files: Vec<String>,
255
+ pub display_name: String,
256
+ pub approx_memory: String,
257
+ pub chat_template: Option<String>,
258
+}
259
+
260
+impl From<FfiUqffModelConfig> for UqffModelConfig {
261
+ fn from(value: FfiUqffModelConfig) -> Self {
262
+ Self {
263
+ model_id: value.model_id,
264
+ files: value.files,
265
+ display_name: value.display_name,
266
+ approx_memory: value.approx_memory,
267
+ chat_template: value.chat_template,
268
+ }
269
+ }
270
+}
271
+
272
+#[derive(Debug, Clone, Copy, uniffi::Enum)]
273
+pub enum FfiEnvironment {
274
+ Development,
275
+ Production,
276
+}
277
+
278
+#[derive(Debug, thiserror::Error, uniffi::Error)]
279
+pub enum FfiEdError {
280
+ #[error("{reason}")]
281
+ Failure { reason: String },
282
+}
283
+
284
+impl From<AgentError> for FfiEdError {
285
+ fn from(value: AgentError) -> Self {
286
+ Self::Failure {
287
+ reason: value.to_string(),
288
+ }
289
+ }
290
+}
291
+
292
+#[uniffi::export(callback_interface)]
293
+#[async_trait]
294
+pub trait FfiToolExecutor: Send + Sync {
295
+ async fn execute(&self, tool_name: String, arguments: String) -> FfiToolOutput;
296
+}
297
+
298
+#[uniffi::export(callback_interface)]
299
+#[async_trait]
300
+pub trait FfiApprovalHandler: Send + Sync {
301
+ async fn approve(&self, call: FfiToolCall) -> FfiApprovalDecision;
302
+}
303
+
304
+#[uniffi::export(callback_interface)]
305
+pub trait FfiEventListener: Send + Sync {
306
+ fn status_changed(&self, update: FfiStatusUpdate);
307
+ fn tool_requested(&self, call: FfiToolCall);
308
+ fn approval_requested(&self, call: FfiToolCall);
309
+ fn tool_started(&self, call: FfiToolCall);
310
+ fn tool_finished(&self, tool_call_id: String, content: String, is_error: bool);
311
+ fn agent_replied(&self, reply: FfiAgentReply);
312
+ fn warning(&self, message: String);
313
+}
314
+
315
+struct ForeignExecutor(Arc<dyn FfiToolExecutor>);
316
+
317
+#[async_trait]
318
+impl ToolExecutor for ForeignExecutor {
319
+ async fn execute(&self, tool_name: &str, arguments: &str) -> Result<String, String> {
320
+ let output = self
321
+ .0
322
+ .execute(tool_name.to_owned(), arguments.to_owned())
323
+ .await;
324
+ match output.error {
325
+ Some(error) => Err(error),
326
+ None => Ok(output.content),
327
+ }
328
+ }
329
+}
330
+
331
+struct ForeignApprovals(Arc<dyn FfiApprovalHandler>);
332
+
333
+#[async_trait]
334
+impl ApprovalHandler for ForeignApprovals {
335
+ async fn approve(&self, request: ApprovalRequest) -> ApprovalDecision {
336
+ self.0.approve((&request.call).into()).await.into()
337
+ }
338
+}
339
+
340
+#[derive(Clone)]
341
+struct FfiSink(Arc<dyn FfiEventListener>);
342
+
343
+impl EventSink for FfiSink {
344
+ fn status_changed(&self, status: EngineStatus, model_name: Option<&str>, error: Option<&str>) {
345
+ self.0.status_changed(FfiStatusUpdate {
346
+ status: status.into(),
347
+ model_name: model_name.map(str::to_owned),
348
+ error: error.map(str::to_owned),
349
+ });
350
+ }
351
+
352
+ fn tool_requested(&self, call: &ToolCall) {
353
+ self.0.tool_requested(call.into());
354
+ }
355
+
356
+ fn approval_requested(&self, request: &ApprovalRequest) {
357
+ self.0.approval_requested((&request.call).into());
358
+ }
359
+
360
+ fn tool_started(&self, call: &ToolCall) {
361
+ self.0.tool_started(call.into());
362
+ }
363
+
364
+ fn tool_finished(&self, result: &ToolExecutionResult) {
365
+ self.0.tool_finished(
366
+ result.tool_call_id.clone(),
367
+ result.content.clone(),
368
+ result.is_error,
369
+ );
370
+ }
371
+
372
+ fn agent_replied(&self, reply: &AgentReply) {
373
+ self.0.agent_replied(reply.into());
374
+ }
375
+
376
+ fn warning(&self, message: &str) {
377
+ self.0.warning(message.to_owned());
378
+ }
379
+}
380
+
381
+#[derive(uniffi::Object)]
382
+pub struct FfiEdAgent {
383
+ inner: Ed<FfiSink>,
384
+}
385
+
386
+#[uniffi::export(async_runtime = "tokio")]
387
+impl FfiEdAgent {
388
+ #[uniffi::constructor]
389
+ pub fn new(
390
+ executor: Box<dyn FfiToolExecutor>,
391
+ approvals: Box<dyn FfiApprovalHandler>,
392
+ events: Box<dyn FfiEventListener>,
393
+ config: Option<FfiAgentConfig>,
394
+ ) -> Arc<Self> {
395
+ let config = config.unwrap_or_default();
396
+ Arc::new(Self {
397
+ inner: Ed::with_agent(
398
+ FfiSink(Arc::from(events)),
399
+ Arc::new(ForeignExecutor(Arc::from(executor))),
400
+ Arc::new(ForeignApprovals(Arc::from(approvals))),
401
+ AgentConfig {
402
+ max_tool_rounds: config.max_tool_rounds,
403
+ max_tool_output_chars: usize::try_from(config.max_tool_output_chars)
404
+ .unwrap_or(usize::MAX),
405
+ },
406
+ ),
407
+ })
408
+ }
409
+
410
+ pub async fn register_tool(&self, tool: FfiToolDefinition) -> Result<(), FfiEdError> {
411
+ self.inner
412
+ .register_tool(AgentToolDefinition {
413
+ name: tool.name,
414
+ description: tool.description,
415
+ parameters_schema: tool.parameters_schema,
416
+ risk: tool.risk.into(),
417
+ })
418
+ .await
419
+ .map_err(Into::into)
420
+ }
421
+
422
+ pub async fn unregister_tool(&self, name: String) -> bool {
423
+ self.inner.unregister_tool(&name).await
424
+ }
425
+
426
+ pub async fn remove_all_tools(&self) {
427
+ self.inner.remove_all_tools().await;
428
+ }
429
+
430
+ pub async fn load_default_agent_model(
431
+ &self,
432
+ system_prompt: Option<String>,
433
+ ) -> Result<f64, FfiEdError> {
434
+ let config: GgufModelConfig = default_agent_model_config().into();
435
+ let sampling = SamplingConfig {
436
+ max_tokens: Some(4096),
437
+ ..SamplingConfig::default()
438
+ };
439
+ self.inner
440
+ .load(config, system_prompt, Some(sampling))
441
+ .await
442
+ .map(|duration| duration.as_secs_f64())
443
+ .map_err(|error| FfiEdError::Failure {
444
+ reason: error.to_string(),
445
+ })
446
+ }
447
+
448
+ pub async fn load_gguf_model(
449
+ &self,
450
+ config: FfiGgufModelConfig,
451
+ system_prompt: Option<String>,
452
+ sampling: Option<FfiSamplingConfig>,
453
+ ) -> Result<f64, FfiEdError> {
454
+ self.inner
455
+ .load(config.into(), system_prompt, sampling.map(Into::into))
456
+ .await
457
+ .map(|duration| duration.as_secs_f64())
458
+ .map_err(|error| FfiEdError::Failure {
459
+ reason: error.to_string(),
460
+ })
461
+ }
462
+
463
+ pub async fn load_uqff_model(
464
+ &self,
465
+ config: FfiUqffModelConfig,
466
+ system_prompt: Option<String>,
467
+ sampling: Option<FfiSamplingConfig>,
468
+ ) -> Result<f64, FfiEdError> {
469
+ let config: UqffModelConfig = config.into();
470
+ let name = config.display_name.clone();
471
+ self.inner
472
+ .load_with(&name, |engine| {
473
+ engine.load_uqff_model(config, system_prompt, sampling.map(Into::into))
474
+ })
475
+ .await
476
+ .map(|duration| duration.as_secs_f64())
477
+ .map_err(|error| FfiEdError::Failure {
478
+ reason: error.to_string(),
479
+ })
480
+ }
481
+
482
+ pub async fn load_assigned_model(
483
+ &self,
484
+ environment: FfiEnvironment,
485
+ app_id: String,
486
+ app_secret: String,
487
+ system_prompt: Option<String>,
488
+ sampling: Option<FfiSamplingConfig>,
489
+ ) -> Result<f64, FfiEdError> {
490
+ let environment = match environment {
491
+ FfiEnvironment::Development => smbcloud_gresiq_sdk::Environment::Dev,
492
+ FfiEnvironment::Production => smbcloud_gresiq_sdk::Environment::Production,
493
+ };
494
+ self.inner
495
+ .load_with("Assigned model", |engine| {
496
+ engine.load_assigned_model(
497
+ environment,
498
+ &app_id,
499
+ &app_secret,
500
+ system_prompt,
501
+ sampling.map(Into::into),
502
+ )
503
+ })
504
+ .await
505
+ .map(|duration| duration.as_secs_f64())
506
+ .map_err(|error| FfiEdError::Failure {
507
+ reason: error.to_string(),
508
+ })
509
+ }
510
+
511
+ pub async fn run(&self, message: String) -> Result<FfiAgentReply, FfiEdError> {
512
+ self.inner
513
+ .run(message)
514
+ .await
515
+ .map(|reply| (&reply).into())
516
+ .map_err(Into::into)
517
+ }
518
+
519
+ pub async fn send(&self, message: String) -> Result<FfiReply, FfiEdError> {
520
+ let reply = self.inner.send(message).await;
521
+ match (reply.reply, reply.duration, reply.error) {
522
+ (Some(text), Some(duration), None) => Ok(FfiReply { text, duration }),
523
+ (_, _, Some(reason)) => Err(FfiEdError::Failure { reason }),
524
+ _ => Err(FfiEdError::Failure {
525
+ reason: "the model returned an empty reply".to_string(),
526
+ }),
527
+ }
528
+ }
529
+
530
+ pub fn cancel(&self) {
531
+ self.inner.cancel();
532
+ }
533
+
534
+ pub async fn unload(&self) -> Option<String> {
535
+ self.inner.unload().await
536
+ }
537
+
538
+ pub async fn info(&self) -> FfiEngineInfo {
539
+ self.inner.info().await.into()
540
+ }
541
+
542
+ pub async fn is_loaded(&self) -> bool {
543
+ self.inner.is_loaded().await
544
+ }
545
+
546
+ pub async fn history(&self) -> Vec<FfiChatMessage> {
547
+ self.inner
548
+ .history()
549
+ .await
550
+ .into_iter()
551
+ .map(Into::into)
552
+ .collect()
553
+ }
554
+
555
+ pub async fn clear_history(&self) -> u64 {
556
+ self.inner.clear_history().await as u64
557
+ }
558
+}
559
+
560
+#[uniffi::export]
561
+pub fn configure_cache_dir(path: String) {
562
+ onde::inference::ffi::configure_cache_dir(path);
563
+}
564
+
565
+#[uniffi::export]
566
+pub fn default_agent_model_config() -> FfiGgufModelConfig {
567
+ #[cfg(any(target_os = "tvos", target_os = "watchos"))]
568
+ let config = GgufModelConfig::qwen3_0_6b();
569
+ #[cfg(target_os = "ios")]
570
+ let config = GgufModelConfig::qwen3_1_7b();
571
+ #[cfg(any(target_os = "macos", target_os = "visionos"))]
572
+ let config = GgufModelConfig::qwen3_4b();
573
+ #[cfg(not(any(
574
+ target_os = "ios",
575
+ target_os = "macos",
576
+ target_os = "tvos",
577
+ target_os = "visionos",
578
+ target_os = "watchos"
579
+ )))]
580
+ let config = GgufModelConfig::qwen3_1_7b();
581
+ config.into()
582
+}
583
+
584
+uniffi::setup_scaffolding!();
crates/ed-agent-tauri/LICENSE-APACHE
new
+202
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright 2026 Splitfire AB (Onde Inference)
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
crates/ed-agent-tauri/LICENSE-MIT
new
+21
@@ -0,0 +1,21 @@
1
+MIT License
2
+
3
+Copyright (c) 2026 Splitfire AB (Onde Inference)
4
+
5
+Permission is hereby granted, free of charge, to any person obtaining a copy
6
+of this software and associated documentation files (the "Software"), to deal
7
+in the Software without restriction, including without limitation the rights
8
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+copies of the Software, and to permit persons to whom the Software is
10
+furnished to do so, subject to the following conditions:
11
+
12
+The above copyright notice and this permission notice shall be included in all
13
+copies or substantial portions of the Software.
14
+
15
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+SOFTWARE.
crates/ed-agent/Cargo.toml
+4
@@ -14,8 +14,12 @@ homepage.workspace = true
14
15
[dependencies]
16
onde.workspace = true
17
+async-trait.workspace = true
18
serde.workspace = true
19
+serde_json.workspace = true
20
log.workspace = true
21
+thiserror.workspace = true
22
+tokio.workspace = true
23
24
[dev-dependencies]
25
tokio = { version = "1", features = ["macros", "rt", "sync"] }
crates/ed-agent/LICENSE-APACHE
new
+202
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright 2026 Splitfire AB (Onde Inference)
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
crates/ed-agent/LICENSE-MIT
new
+21
@@ -0,0 +1,21 @@
1
+MIT License
2
+
3
+Copyright (c) 2026 Splitfire AB (Onde Inference)
4
+
5
+Permission is hereby granted, free of charge, to any person obtaining a copy
6
+of this software and associated documentation files (the "Software"), to deal
7
+in the Software without restriction, including without limitation the rights
8
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+copies of the Software, and to permit persons to whom the Software is
10
+furnished to do so, subject to the following conditions:
11
+
12
+The above copyright notice and this permission notice shall be included in all
13
+copies or substantial portions of the Software.
14
+
15
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+SOFTWARE.
crates/ed-agent/README.md
+6
-4
@@ -11,7 +11,9 @@ application built on it was writing for itself.
11
not one call. Ed drives `Loading -> Ready` (or `-> Error`) and reports each
12
transition.
13
- **Notification.** `onde` exposes status by polling. A UI needs to be told. Ed
14
- pushes transitions and replies to a `StatusSink` you implement.
14
+ pushes transitions, tool activity, and replies to an `EventSink` you implement.
15
+- **Tool calling.** The host registers an explicit JSON-schema capability list.
16
+ Read-only tools run automatically; mutating tools require host approval.
17
- **A flattened reply.** Inference returns `Result<InferenceResult, _>`; a
18
frontend wants one shape covering both outcomes. That's `ChatReply`.
19
@@ -30,8 +32,8 @@ let reply = ed.send("Summarise this thread.").await;
32
println!("{}", reply.reply.unwrap_or_default());
33
```
34
33
-To hear about state changes, implement `StatusSink` and construct with
34
-`Ed::with_sink`. For Tauri applications,
35
+Use `Ed::with_agent` to provide a `ToolExecutor`, `ApprovalHandler`, and
36
+`EventSink`. For Tauri applications,
37
[`ed-agent-tauri`](https://crates.io/crates/ed-agent-tauri) provides that
38
implementation plus the commands a webview invokes.
39
@@ -65,4 +67,4 @@ support list silently disables chat on a platform that would have worked.
67
## Copyright
68
69
© 2026 [Splitfire AB](https://5mb.app) ([Onde Inference](https://ondeinference.com)).
68
-Licensed under Apache-2.0.
70
+Licensed under MIT or Apache-2.0.
crates/ed-agent/src/ed.rs
+409
-3
@@ -1,15 +1,23 @@
1
//! The agent handle.
2
3
+use std::collections::{HashMap, HashSet};
4
use std::future::Future;
5
+use std::sync::atomic::{AtomicBool, Ordering};
6
+use std::sync::Arc;
7
use std::time::Duration;
8
9
use log::{error, info};
10
use onde::inference::types::{format_duration, ChatMessage};
11
use onde::inference::{
12
ChatEngine, EngineInfo, EngineStatus, GgufModelConfig, InferenceError, SamplingConfig,
13
+ ToolCallingSupport, ToolDefinition, ToolResult,
14
};
15
12
-use crate::{ChatReply, NoopSink, StatusSink};
16
+use crate::{
17
+ tool::truncate_output, AgentConfig, AgentError, AgentReply, AgentToolDefinition,
18
+ ApprovalDecision, ApprovalHandler, ApprovalRequest, ChatReply, DenyApprovals, EventSink,
19
+ NoopSink, RejectingExecutor, ToolCall, ToolExecutionResult, ToolExecutor, ToolRisk,
20
+};
21
22
/// An embeddable chat agent over a single [`ChatEngine`].
23
///
@@ -21,6 +29,13 @@ use crate::{ChatReply, NoopSink, StatusSink};
29
pub struct Ed<S = NoopSink> {
30
engine: ChatEngine,
31
sink: S,
32
+ tools: tokio::sync::RwLock<HashMap<String, AgentToolDefinition>>,
33
+ executor: Arc<dyn ToolExecutor>,
34
+ approvals: Arc<dyn ApprovalHandler>,
35
+ session_grants: tokio::sync::Mutex<HashSet<String>>,
36
+ turn: tokio::sync::Mutex<()>,
37
+ cancelled: AtomicBool,
38
+ agent_config: AgentConfig,
39
}
40
41
impl Ed<NoopSink> {
@@ -36,12 +51,34 @@ impl Default for Ed<NoopSink> {
51
}
52
}
53
39
-impl<S: StatusSink> Ed<S> {
54
+impl<S: EventSink> Ed<S> {
55
/// An agent that reports transitions and replies to `sink`.
56
pub fn with_sink(sink: S) -> Self {
57
+ Self::with_agent(
58
+ sink,
59
+ Arc::new(RejectingExecutor),
60
+ Arc::new(DenyApprovals),
61
+ AgentConfig::default(),
62
+ )
63
+ }
64
+
65
+ /// Build an agent with host-defined tool execution and approval handling.
66
+ pub fn with_agent(
67
+ sink: S,
68
+ executor: Arc<dyn ToolExecutor>,
69
+ approvals: Arc<dyn ApprovalHandler>,
70
+ config: AgentConfig,
71
+ ) -> Self {
72
Self {
73
engine: ChatEngine::new(),
74
sink,
75
+ tools: tokio::sync::RwLock::new(HashMap::new()),
76
+ executor,
77
+ approvals,
78
+ session_grants: tokio::sync::Mutex::new(HashSet::new()),
79
+ turn: tokio::sync::Mutex::new(()),
80
+ cancelled: AtomicBool::new(false),
81
+ agent_config: config.validated(),
82
}
83
}
84
@@ -50,6 +87,13 @@ impl<S: StatusSink> Ed<S> {
87
Self {
88
engine: ChatEngine::with_app_id(onde_app_id),
89
sink,
90
+ tools: tokio::sync::RwLock::new(HashMap::new()),
91
+ executor: Arc::new(RejectingExecutor),
92
+ approvals: Arc::new(DenyApprovals),
93
+ session_grants: tokio::sync::Mutex::new(HashSet::new()),
94
+ turn: tokio::sync::Mutex::new(()),
95
+ cancelled: AtomicBool::new(false),
96
+ agent_config: AgentConfig::default(),
97
}
98
}
99
@@ -171,6 +215,220 @@ impl<S: StatusSink> Ed<S> {
215
reply
216
}
217
218
+ /// Register a host capability that the model may request.
219
+ pub async fn register_tool(&self, tool: AgentToolDefinition) -> Result<(), AgentError> {
220
+ tool.validate()?;
221
+ let mut tools = self.tools.write().await;
222
+ if tools.contains_key(&tool.name) {
223
+ return Err(AgentError::DuplicateTool {
224
+ name: tool.name.clone(),
225
+ });
226
+ }
227
+ tools.insert(tool.name.clone(), tool);
228
+ Ok(())
229
+ }
230
+
231
+ pub async fn unregister_tool(&self, name: &str) -> bool {
232
+ self.tools.write().await.remove(name).is_some()
233
+ }
234
+
235
+ pub async fn remove_all_tools(&self) {
236
+ self.tools.write().await.clear();
237
+ self.session_grants.lock().await.clear();
238
+ }
239
+
240
+ /// Request cancellation of the active agent turn.
241
+ pub fn cancel(&self) {
242
+ self.cancelled.store(true, Ordering::Release);
243
+ }
244
+
245
+ async fn execute_tool_call(
246
+ &self,
247
+ call: &ToolCall,
248
+ registered: &HashMap<String, AgentToolDefinition>,
249
+ ) -> ToolExecutionResult {
250
+ let Some(definition) = registered.get(&call.name) else {
251
+ return ToolExecutionResult {
252
+ tool_call_id: call.id.clone(),
253
+ content: format!("tool {:?} is not registered", call.name),
254
+ is_error: true,
255
+ };
256
+ };
257
+
258
+ if !matches!(
259
+ serde_json::from_str::<serde_json::Value>(&call.arguments),
260
+ Ok(serde_json::Value::Object(_))
261
+ ) {
262
+ return ToolExecutionResult {
263
+ tool_call_id: call.id.clone(),
264
+ content: "tool arguments must be a valid JSON object".to_string(),
265
+ is_error: true,
266
+ };
267
+ }
268
+
269
+ let approved = if definition.risk == ToolRisk::ReadOnly
270
+ || self.session_grants.lock().await.contains(&call.name)
271
+ {
272
+ true
273
+ } else {
274
+ let request = ApprovalRequest {
275
+ call: call.clone(),
276
+ risk: definition.risk,
277
+ };
278
+ self.sink.approval_requested(&request);
279
+ match self.approvals.approve(request).await {
280
+ ApprovalDecision::AllowOnce => true,
281
+ ApprovalDecision::AllowForSession => {
282
+ self.session_grants.lock().await.insert(call.name.clone());
283
+ true
284
+ }
285
+ ApprovalDecision::Deny => false,
286
+ }
287
+ };
288
+
289
+ if !approved {
290
+ return ToolExecutionResult {
291
+ tool_call_id: call.id.clone(),
292
+ content: format!("tool {:?} was denied by the user", call.name),
293
+ is_error: true,
294
+ };
295
+ }
296
+
297
+ self.sink.tool_started(call);
298
+ match self.executor.execute(&call.name, &call.arguments).await {
299
+ Ok(content) => ToolExecutionResult {
300
+ tool_call_id: call.id.clone(),
301
+ content: truncate_output(content, self.agent_config.max_tool_output_chars),
302
+ is_error: false,
303
+ },
304
+ Err(error) => ToolExecutionResult {
305
+ tool_call_id: call.id.clone(),
306
+ content: truncate_output(
307
+ format!("tool execution failed: {error}"),
308
+ self.agent_config.max_tool_output_chars,
309
+ ),
310
+ is_error: true,
311
+ },
312
+ }
313
+ }
314
+
315
+ /// Run a complete local agent turn, including any requested host tools.
316
+ pub async fn run(&self, message: impl Into<String>) -> Result<AgentReply, AgentError> {
317
+ let _turn = self.turn.lock().await;
318
+ self.cancelled.store(false, Ordering::Release);
319
+
320
+ let registered = self.tools.read().await.clone();
321
+ if registered.is_empty() {
322
+ let reply = self.send(message).await;
323
+ return match (reply.reply, reply.duration, reply.error) {
324
+ (Some(text), Some(duration), None) => {
325
+ let agent_reply = AgentReply {
326
+ text,
327
+ duration_seconds: 0.0,
328
+ duration,
329
+ tool_rounds: 0,
330
+ };
331
+ self.sink.agent_replied(&agent_reply);
332
+ Ok(agent_reply)
333
+ }
334
+ (_, _, Some(reason)) => Err(AgentError::Inference { reason }),
335
+ _ => Err(AgentError::EmptyReply),
336
+ };
337
+ }
338
+
339
+ match self.engine.tool_calling_support().await {
340
+ ToolCallingSupport::Unsupported => return Err(AgentError::UnsupportedModel),
341
+ ToolCallingSupport::Unknown => self
342
+ .sink
343
+ .warning("the loaded model has not been verified for tool calling"),
344
+ ToolCallingSupport::Supported => {}
345
+ }
346
+
347
+ let mut definitions: Vec<ToolDefinition> = registered
348
+ .values()
349
+ .map(|tool| ToolDefinition {
350
+ name: tool.name.clone(),
351
+ description: tool.description.clone(),
352
+ parameters_schema: tool.parameters_schema.clone(),
353
+ })
354
+ .collect();
355
+ definitions.sort_by(|left, right| left.name.cmp(&right.name));
356
+
357
+ let mut result = self
358
+ .engine
359
+ .send_message_with_tools(message.into(), &definitions)
360
+ .await
361
+ .map_err(|error| AgentError::Inference {
362
+ reason: error.to_string(),
363
+ })?;
364
+ let mut duration_seconds = result.duration_secs;
365
+ let mut rounds = 0_u8;
366
+
367
+ while !result.tool_calls.is_empty() {
368
+ rounds += 1;
369
+ let mut tool_results = Vec::with_capacity(result.tool_calls.len());
370
+
371
+ for (index, requested) in result.tool_calls.iter().enumerate() {
372
+ if self.cancelled.load(Ordering::Acquire) {
373
+ for pending in &result.tool_calls[index..] {
374
+ tool_results.push(ToolResult {
375
+ tool_call_id: pending.id.clone(),
376
+ content: "tool was not executed because the user cancelled the turn"
377
+ .to_string(),
378
+ });
379
+ }
380
+ self.engine.record_tool_results(tool_results).await;
381
+ return Err(AgentError::Cancelled);
382
+ }
383
+
384
+ let call = ToolCall {
385
+ id: requested.id.clone(),
386
+ name: requested.function_name.clone(),
387
+ arguments: requested.arguments.clone(),
388
+ };
389
+ self.sink.tool_requested(&call);
390
+
391
+ let execution = self.execute_tool_call(&call, ®istered).await;
392
+ self.sink.tool_finished(&execution);
393
+ tool_results.push(ToolResult {
394
+ tool_call_id: execution.tool_call_id,
395
+ content: execution.content,
396
+ });
397
+ }
398
+
399
+ if self.cancelled.load(Ordering::Acquire) {
400
+ self.engine.record_tool_results(tool_results).await;
401
+ return Err(AgentError::Cancelled);
402
+ }
403
+
404
+ let next_tools = if rounds < self.agent_config.max_tool_rounds {
405
+ Some(definitions.as_slice())
406
+ } else {
407
+ None
408
+ };
409
+ result = self
410
+ .engine
411
+ .send_tool_results(tool_results, next_tools)
412
+ .await
413
+ .map_err(|error| AgentError::Inference {
414
+ reason: error.to_string(),
415
+ })?;
416
+ duration_seconds += result.duration_secs;
417
+ }
418
+
419
+ if result.text.trim().is_empty() {
420
+ return Err(AgentError::EmptyReply);
421
+ }
422
+ let reply = AgentReply {
423
+ text: result.text,
424
+ duration_seconds,
425
+ duration: format_duration(Duration::from_secs_f64(duration_seconds)),
426
+ tool_rounds: rounds,
427
+ };
428
+ self.sink.agent_replied(&reply);
429
+ Ok(reply)
430
+ }
431
+
432
/// Current status, loaded model, footprint, and history length.
433
pub async fn info(&self) -> EngineInfo {
434
self.engine.info().await
@@ -199,6 +457,7 @@ impl<S: StatusSink> Ed<S> {
457
458
#[cfg(test)]
459
mod tests {
460
+ use std::sync::atomic::{AtomicUsize, Ordering};
461
use std::sync::Mutex;
462
463
use super::*;
@@ -222,7 +481,7 @@ mod tests {
481
}
482
}
483
225
- impl StatusSink for &'static RecordingSink {
484
+ impl EventSink for &'static RecordingSink {
485
fn status_changed(
486
&self,
487
status: EngineStatus,
@@ -245,6 +504,33 @@ mod tests {
504
Box::leak(Box::new(RecordingSink::default()))
505
}
506
507
+ struct CountingExecutor(AtomicUsize);
508
+
509
+ #[async_trait::async_trait]
510
+ impl ToolExecutor for CountingExecutor {
511
+ async fn execute(&self, tool_name: &str, _: &str) -> Result<String, String> {
512
+ self.0.fetch_add(1, Ordering::Relaxed);
513
+ Ok(format!("{tool_name} completed"))
514
+ }
515
+ }
516
+
517
+ struct FixedApproval(ApprovalDecision);
518
+
519
+ #[async_trait::async_trait]
520
+ impl ApprovalHandler for FixedApproval {
521
+ async fn approve(&self, _: ApprovalRequest) -> ApprovalDecision {
522
+ self.0
523
+ }
524
+ }
525
+
526
+ fn call(name: &str) -> ToolCall {
527
+ ToolCall {
528
+ id: "call-1".to_owned(),
529
+ name: name.to_owned(),
530
+ arguments: "{}".to_owned(),
531
+ }
532
+ }
533
+
534
#[tokio::test]
535
async fn a_fresh_agent_reports_itself_unloaded_and_empty() {
536
let ed = Ed::new();
@@ -331,4 +617,124 @@ mod tests {
617
);
618
assert_eq!(sink.errors(), vec![None, Some("no weights".to_owned())]);
619
}
620
+
621
+ #[tokio::test]
622
+ async fn read_only_tools_run_without_an_approval_round_trip() {
623
+ let executor = Arc::new(CountingExecutor(AtomicUsize::new(0)));
624
+ let ed = Ed::with_agent(
625
+ NoopSink,
626
+ executor.clone(),
627
+ Arc::new(FixedApproval(ApprovalDecision::Deny)),
628
+ AgentConfig::default(),
629
+ );
630
+ ed.register_tool(AgentToolDefinition::read_only(
631
+ "clock",
632
+ "Read the clock",
633
+ r#"{"type":"object"}"#,
634
+ ))
635
+ .await
636
+ .unwrap();
637
+ let tools = ed.tools.read().await.clone();
638
+
639
+ let result = ed.execute_tool_call(&call("clock"), &tools).await;
640
+
641
+ assert!(!result.is_error);
642
+ assert_eq!(executor.0.load(Ordering::Relaxed), 1);
643
+ }
644
+
645
+ #[tokio::test]
646
+ async fn denied_mutating_tools_never_reach_the_executor() {
647
+ let executor = Arc::new(CountingExecutor(AtomicUsize::new(0)));
648
+ let ed = Ed::with_agent(
649
+ NoopSink,
650
+ executor.clone(),
651
+ Arc::new(FixedApproval(ApprovalDecision::Deny)),
652
+ AgentConfig::default(),
653
+ );
654
+ ed.register_tool(AgentToolDefinition::mutating(
655
+ "save_note",
656
+ "Save a note",
657
+ r#"{"type":"object"}"#,
658
+ ))
659
+ .await
660
+ .unwrap();
661
+ let tools = ed.tools.read().await.clone();
662
+
663
+ let result = ed.execute_tool_call(&call("save_note"), &tools).await;
664
+
665
+ assert!(result.is_error);
666
+ assert!(result.content.contains("denied"));
667
+ assert_eq!(executor.0.load(Ordering::Relaxed), 0);
668
+ }
669
+
670
+ #[tokio::test]
671
+ async fn a_session_grant_applies_to_later_calls_of_the_same_tool() {
672
+ let executor = Arc::new(CountingExecutor(AtomicUsize::new(0)));
673
+ let ed = Ed::with_agent(
674
+ NoopSink,
675
+ executor.clone(),
676
+ Arc::new(FixedApproval(ApprovalDecision::AllowForSession)),
677
+ AgentConfig::default(),
678
+ );
679
+ ed.register_tool(AgentToolDefinition::mutating(
680
+ "save_note",
681
+ "Save a note",
682
+ r#"{"type":"object"}"#,
683
+ ))
684
+ .await
685
+ .unwrap();
686
+ let tools = ed.tools.read().await.clone();
687
+
688
+ ed.execute_tool_call(&call("save_note"), &tools).await;
689
+ ed.execute_tool_call(&call("save_note"), &tools).await;
690
+
691
+ assert_eq!(executor.0.load(Ordering::Relaxed), 2);
692
+ assert!(ed.session_grants.lock().await.contains("save_note"));
693
+ }
694
+
695
+ #[tokio::test]
696
+ async fn unknown_tools_return_an_error_result_instead_of_executing() {
697
+ let executor = Arc::new(CountingExecutor(AtomicUsize::new(0)));
698
+ let ed = Ed::with_agent(
699
+ NoopSink,
700
+ executor.clone(),
701
+ Arc::new(FixedApproval(ApprovalDecision::AllowOnce)),
702
+ AgentConfig::default(),
703
+ );
704
+
705
+ let result = ed
706
+ .execute_tool_call(&call("missing"), &HashMap::new())
707
+ .await;
708
+
709
+ assert!(result.is_error);
710
+ assert!(result.content.contains("not registered"));
711
+ assert_eq!(executor.0.load(Ordering::Relaxed), 0);
712
+ }
713
+
714
+ #[tokio::test]
715
+ async fn tool_arguments_must_be_a_json_object() {
716
+ let executor = Arc::new(CountingExecutor(AtomicUsize::new(0)));
717
+ let ed = Ed::with_agent(
718
+ NoopSink,
719
+ executor.clone(),
720
+ Arc::new(FixedApproval(ApprovalDecision::AllowOnce)),
721
+ AgentConfig::default(),
722
+ );
723
+ ed.register_tool(AgentToolDefinition::read_only(
724
+ "clock",
725
+ "Read the clock",
726
+ r#"{"type":"object"}"#,
727
+ ))
728
+ .await
729
+ .unwrap();
730
+ let tools = ed.tools.read().await.clone();
731
+ let mut invalid = call("clock");
732
+ invalid.arguments = "[]".to_owned();
733
+
734
+ let result = ed.execute_tool_call(&invalid, &tools).await;
735
+
736
+ assert!(result.is_error);
737
+ assert!(result.content.contains("JSON object"));
738
+ assert_eq!(executor.0.load(Ordering::Relaxed), 0);
739
+ }
740
}
crates/ed-agent/src/lib.rs
+9
-2
@@ -46,14 +46,21 @@
46
mod ed;
47
mod reply;
48
mod sink;
49
+mod tool;
50
51
pub use ed::Ed;
52
pub use reply::ChatReply;
52
-pub use sink::{NoopSink, StatusSink};
53
+pub use sink::{EventSink, NoopSink, StatusSink};
54
+pub use tool::{
55
+ AgentConfig, AgentError, AgentReply, AgentToolDefinition, ApprovalDecision, ApprovalHandler,
56
+ ApprovalRequest, DenyApprovals, RejectingExecutor, ToolCall, ToolExecutionResult, ToolExecutor,
57
+ ToolRisk,
58
+};
59
60
// Re-exported so a host depends on `ed-agent` alone and never has to match a
61
// second `onde` version against this crate's.
56
-pub use onde::inference::types::ChatMessage;
62
+pub use onde::inference::types::{ChatMessage, ChatRole};
63
pub use onde::inference::{
64
ChatEngine, EngineInfo, EngineStatus, GgufModelConfig, InferenceError, SamplingConfig,
65
+ ToolCallingSupport, UqffModelConfig,
66
};
crates/ed-agent/src/sink.rs
+21
-8
@@ -2,7 +2,7 @@
2
3
use onde::inference::EngineStatus;
4
5
-use crate::ChatReply;
5
+use crate::{AgentReply, ApprovalRequest, ChatReply, ToolCall, ToolExecutionResult};
6
7
/// Receives lifecycle transitions and completed replies.
8
///
@@ -17,12 +17,18 @@ use crate::ChatReply;
17
/// Ed is mid-operation. Hand off to a channel or an event bus rather than
18
/// blocking. Implementations must be `Send + Sync` because a load or a send
19
/// may be driven from any task.
20
-pub trait StatusSink: Send + Sync + 'static {
20
+pub trait EventSink: Send + Sync + 'static {
21
/// The engine moved to a new lifecycle state.
22
///
23
/// `model_name` is the model involved where one is known, and `error`
24
/// carries the reason when `status` is [`EngineStatus::Error`].
25
- fn status_changed(&self, status: EngineStatus, model_name: Option<&str>, error: Option<&str>);
25
+ fn status_changed(
26
+ &self,
27
+ _status: EngineStatus,
28
+ _model_name: Option<&str>,
29
+ _error: Option<&str>,
30
+ ) {
31
+ }
32
33
/// An inference turn finished, successfully or not.
34
///
@@ -30,9 +36,19 @@ pub trait StatusSink: Send + Sync + 'static {
36
/// inference is slow: a host that awaits the call from a UI callback can
37
/// have that callback collected before it resolves. Emitting lets the view
38
/// pick the result up independently.
33
- fn replied(&self, reply: &ChatReply);
39
+ fn replied(&self, _reply: &ChatReply) {}
40
+
41
+ fn tool_requested(&self, _call: &ToolCall) {}
42
+ fn approval_requested(&self, _request: &ApprovalRequest) {}
43
+ fn tool_started(&self, _call: &ToolCall) {}
44
+ fn tool_finished(&self, _result: &ToolExecutionResult) {}
45
+ fn agent_replied(&self, _reply: &AgentReply) {}
46
+ fn warning(&self, _message: &str) {}
47
}
48
49
+/// Compatibility name retained for the original Ed API.
50
+pub use EventSink as StatusSink;
51
+
52
/// A [`StatusSink`] that drops everything.
53
///
54
/// The default for [`Ed::new`](crate::Ed::new), and what you want in tests or
@@ -40,7 +56,4 @@ pub trait StatusSink: Send + Sync + 'static {
56
#[derive(Debug, Clone, Copy, Default)]
57
pub struct NoopSink;
58
43
-impl StatusSink for NoopSink {
44
- fn status_changed(&self, _: EngineStatus, _: Option<&str>, _: Option<&str>) {}
45
- fn replied(&self, _: &ChatReply) {}
46
-}
59
+impl EventSink for NoopSink {}
crates/ed-agent/src/tool.rs
new
+223
@@ -0,0 +1,223 @@
1
+//! Host-defined tools and the policies that guard them.
2
+
3
+use async_trait::async_trait;
4
+use serde::{Deserialize, Serialize};
5
+
6
+/// A tool the model may ask the host application to run.
7
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8
+pub struct AgentToolDefinition {
9
+ pub name: String,
10
+ pub description: String,
11
+ /// JSON Schema encoded as a string.
12
+ pub parameters_schema: String,
13
+ pub risk: ToolRisk,
14
+}
15
+
16
+impl AgentToolDefinition {
17
+ pub fn read_only(
18
+ name: impl Into<String>,
19
+ description: impl Into<String>,
20
+ parameters_schema: impl Into<String>,
21
+ ) -> Self {
22
+ Self {
23
+ name: name.into(),
24
+ description: description.into(),
25
+ parameters_schema: parameters_schema.into(),
26
+ risk: ToolRisk::ReadOnly,
27
+ }
28
+ }
29
+
30
+ pub fn mutating(
31
+ name: impl Into<String>,
32
+ description: impl Into<String>,
33
+ parameters_schema: impl Into<String>,
34
+ ) -> Self {
35
+ Self {
36
+ name: name.into(),
37
+ description: description.into(),
38
+ parameters_schema: parameters_schema.into(),
39
+ risk: ToolRisk::Mutating,
40
+ }
41
+ }
42
+
43
+ pub(crate) fn validate(&self) -> Result<(), AgentError> {
44
+ let mut chars = self.name.chars();
45
+ let valid_first = chars.next().is_some_and(|c| c.is_ascii_alphabetic());
46
+ let valid_rest = chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
47
+ if !valid_first || !valid_rest || self.name.len() > 64 {
48
+ return Err(AgentError::InvalidTool {
49
+ reason: format!(
50
+ "tool name {:?} must start with an ASCII letter and contain only letters, numbers, '_' or '-' (maximum 64 bytes)",
51
+ self.name
52
+ ),
53
+ });
54
+ }
55
+ if self.description.trim().is_empty() {
56
+ return Err(AgentError::InvalidTool {
57
+ reason: format!("tool {:?} needs a description", self.name),
58
+ });
59
+ }
60
+ let schema: serde_json::Value =
61
+ serde_json::from_str(&self.parameters_schema).map_err(|error| {
62
+ AgentError::InvalidTool {
63
+ reason: format!("tool {:?} has invalid JSON Schema: {error}", self.name),
64
+ }
65
+ })?;
66
+ if schema.get("type").and_then(serde_json::Value::as_str) != Some("object") {
67
+ return Err(AgentError::InvalidTool {
68
+ reason: format!("tool {:?} schema must have type object", self.name),
69
+ });
70
+ }
71
+ Ok(())
72
+ }
73
+}
74
+
75
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
76
+pub enum ToolRisk {
77
+ ReadOnly,
78
+ Mutating,
79
+}
80
+
81
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82
+pub struct ToolCall {
83
+ pub id: String,
84
+ pub name: String,
85
+ pub arguments: String,
86
+}
87
+
88
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89
+pub struct ToolExecutionResult {
90
+ pub tool_call_id: String,
91
+ pub content: String,
92
+ pub is_error: bool,
93
+}
94
+
95
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96
+pub struct ApprovalRequest {
97
+ pub call: ToolCall,
98
+ pub risk: ToolRisk,
99
+}
100
+
101
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102
+pub enum ApprovalDecision {
103
+ AllowOnce,
104
+ AllowForSession,
105
+ Deny,
106
+}
107
+
108
+#[async_trait]
109
+pub trait ToolExecutor: Send + Sync + 'static {
110
+ async fn execute(&self, tool_name: &str, arguments: &str) -> Result<String, String>;
111
+}
112
+
113
+#[async_trait]
114
+pub trait ApprovalHandler: Send + Sync + 'static {
115
+ async fn approve(&self, request: ApprovalRequest) -> ApprovalDecision;
116
+}
117
+
118
+#[derive(Debug, Default)]
119
+pub struct RejectingExecutor;
120
+
121
+#[async_trait]
122
+impl ToolExecutor for RejectingExecutor {
123
+ async fn execute(&self, tool_name: &str, _: &str) -> Result<String, String> {
124
+ Err(format!("no executor is registered for tool {tool_name:?}"))
125
+ }
126
+}
127
+
128
+#[derive(Debug, Default)]
129
+pub struct DenyApprovals;
130
+
131
+#[async_trait]
132
+impl ApprovalHandler for DenyApprovals {
133
+ async fn approve(&self, _: ApprovalRequest) -> ApprovalDecision {
134
+ ApprovalDecision::Deny
135
+ }
136
+}
137
+
138
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
139
+pub struct AgentConfig {
140
+ pub max_tool_rounds: u8,
141
+ pub max_tool_output_chars: usize,
142
+}
143
+
144
+impl Default for AgentConfig {
145
+ fn default() -> Self {
146
+ Self {
147
+ max_tool_rounds: 8,
148
+ max_tool_output_chars: 10_000,
149
+ }
150
+ }
151
+}
152
+
153
+impl AgentConfig {
154
+ pub(crate) fn validated(self) -> Self {
155
+ Self {
156
+ max_tool_rounds: self.max_tool_rounds.clamp(1, 24),
157
+ max_tool_output_chars: self.max_tool_output_chars.max(1),
158
+ }
159
+ }
160
+}
161
+
162
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
163
+pub struct AgentReply {
164
+ pub text: String,
165
+ pub duration_seconds: f64,
166
+ pub duration: String,
167
+ pub tool_rounds: u8,
168
+}
169
+
170
+#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
171
+pub enum AgentError {
172
+ #[error("invalid tool: {reason}")]
173
+ InvalidTool { reason: String },
174
+ #[error("a tool named {name:?} is already registered")]
175
+ DuplicateTool { name: String },
176
+ #[error("the loaded model does not support tool calling")]
177
+ UnsupportedModel,
178
+ #[error("the agent turn was cancelled")]
179
+ Cancelled,
180
+ #[error("the model returned an empty final reply")]
181
+ EmptyReply,
182
+ #[error("inference failed: {reason}")]
183
+ Inference { reason: String },
184
+}
185
+
186
+pub(crate) fn truncate_output(value: String, max_chars: usize) -> String {
187
+ if value.chars().count() <= max_chars {
188
+ return value;
189
+ }
190
+ let mut truncated: String = value.chars().take(max_chars).collect();
191
+ truncated.push_str("\n[tool output truncated by Ed]");
192
+ truncated
193
+}
194
+
195
+#[cfg(test)]
196
+mod tests {
197
+ use super::*;
198
+
199
+ #[test]
200
+ fn validates_tool_names_and_object_schemas() {
201
+ AgentToolDefinition::read_only("clock", "Read the clock", r#"{"type":"object"}"#)
202
+ .validate()
203
+ .unwrap();
204
+ assert!(
205
+ AgentToolDefinition::read_only("bad name", "Read", r#"{"type":"object"}"#)
206
+ .validate()
207
+ .is_err()
208
+ );
209
+ assert!(
210
+ AgentToolDefinition::read_only("clock", "Read", r#"{"type":"array"}"#)
211
+ .validate()
212
+ .is_err()
213
+ );
214
+ }
215
+
216
+ #[test]
217
+ fn truncation_is_character_safe_and_explicit() {
218
+ assert_eq!(
219
+ truncate_output("héllo".into(), 2),
220
+ "hé\n[tool output truncated by Ed]"
221
+ );
222
+ }
223
+}
rust-toolchain.toml
new
+2
@@ -0,0 +1,2 @@
1
+[toolchain]
2
+channel = "1.94.0"
uniffi-bindgen/Cargo.toml
new
+12
@@ -0,0 +1,12 @@
1
+[package]
2
+name = "ed-uniffi-bindgen"
3
+version = "0.1.0"
4
+edition = "2021"
5
+publish = false
6
+
7
+[[bin]]
8
+name = "uniffi-bindgen"
9
+path = "uniffi-bindgen.rs"
10
+
11
+[dependencies]
12
+uniffi = { version = "=0.31.0", features = ["cli"] }
uniffi-bindgen/uniffi-bindgen.rs
new
+3
@@ -0,0 +1,3 @@
1
+fn main() {
2
+ uniffi::uniffi_bindgen_main()
3
+}