@ondeinference / ed / commits / 6d39dfd

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, &registered).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 +}