@ondeinference / ed / commits / a91ef88

Stop the agent loop when the tool budget runs out, and fix CI

max_tool_rounds only ever stopped Ed offering tools. It never stopped the model asking, and onde parses tool calls out of every response whether tools were sent or not, so the loop had no exit of its own: a model that kept emitting tool-call syntax ran until the u8 round counter overflowed and wrapped back under the limit, which in release means running forever. The budget is now checked at the top of each round as well, through one predicate used by both checks so they cannot drift apart. Hitting it reports a warning, answers the calls left dangling so the next turn's history is still valid, and returns ToolRoundsExhausted if there is no usable text by then. A turn with no tools registered went through send, which reports a ChatReply, and then reported an AgentReply as well, so a host listening for both rendered the same answer twice. It also hardcoded duration_seconds to 0.0 while filling in the real duration string, which meant every FFI consumer reading that field got zero. Both come from calling the engine directly instead. clear_history now drops session grants. The user approved a mutating tool for a conversation; clearing the history ends that conversation, and carrying the grant into the next one keeps consent alive past the thing it was given for. Onde app id and host tools were mutually exclusive, since with_app_id built its own defaults rather than delegating. Both now go through with_agent_and_app_id. The FFI approval callbacks only received the tool call, not its risk, so a Swift approval sheet could not tell the user whether the call was about to change anything. That is the reason the sheet is on screen, so it now travels with it. TauriSink implemented two of the eight sink methods, which left a Tauri frontend blind to everything about tool calling. It forwards all of them now, and there are chat_run and chat_cancel commands to drive a turn. Approvals stay the host's: answering one means a webview round trip inside an awaited Rust call, and hosts differ enough about how to wire that up that guessing here would be worse than the event plus a note saying so. CI could not have run. Both jobs checked out this repo alone, but the workspace depends on onde by path, so every cargo invocation would have failed on the missing sibling. Both jobs now check out the two repos side by side, with the onde ref in one place at the top. Preflight moves to macOS: the artifact is an XCFramework and the toolchain is Apple throughout, so building mistral.rs and a GTK stack on Linux bought coverage nobody ships. The publish dry run loses --locked, because packaging swaps the path dependency for the registry one and that is a lockfile change by definition. There is also a CI workflow now, so pull requests run fmt, clippy, and tests at all. The repository URL said ondeinference/onde-ed again. It was fixed once and came back with the tool-calling commit, and it is permanent once published. Also documented the ___chkstk_darwin stub, which was six lines of assembly with no explanation of what breaks without it or when it can go; narrowed the compiles-everywhere claim to the platforms onde actually supports, since Ed does not build on the ones where onde falls back to a stub; and pointed the tauri crate's licence line at MIT or Apache-2.0, which is what its Cargo.toml and both LICENSE files already said. Co-Authored-By: siGit Code <noreply@sigit.si>

Seto Elkahfi committed Sep 10, 2026 at 00:07 UTC a91ef883e5ea59c0f10b66d8eea6c41a2048d1a1
13 files changed +478 -74
.github/workflows/ci.yml new
+45
@@ -0,0 +1,45 @@
1 +name: CI
2 +
3 +on:
4 + pull_request:
5 + push:
6 + branches: [main, development]
7 +
8 +concurrency:
9 + group: ci-${{ github.ref }}
10 + cancel-in-progress: true
11 +
12 +env:
13 + # See the note in release-sdk-swift.yml: Ed and onde are checked out side by
14 + # side because the dependency is a path dependency.
15 + ONDE_REF: main
16 +
17 +jobs:
18 + check:
19 + runs-on: macos-26
20 + defaults:
21 + run:
22 + working-directory: ed
23 + steps:
24 + - uses: actions/checkout@v6
25 + with:
26 + path: ed
27 + - uses: actions/checkout@v6
28 + with:
29 + repository: ondeinference/onde
30 + ref: ${{ env.ONDE_REF }}
31 + path: onde
32 + - name: Read Rust toolchain
33 + run: |
34 + rust_toolchain="$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml | head -n 1)"
35 + test -n "$rust_toolchain"
36 + echo "RUST_TOOLCHAIN=$rust_toolchain" >> "$GITHUB_ENV"
37 + - uses: dtolnay/rust-toolchain@stable
38 + with:
39 + toolchain: ${{ env.RUST_TOOLCHAIN }}
40 + - uses: Swatinem/rust-cache@v2
41 + with:
42 + workspaces: ed
43 + - run: cargo fmt --all -- --check
44 + - run: cargo clippy --locked --workspace --all-targets -- -D warnings
45 + - run: cargo test --locked --workspace
.github/workflows/release-sdk-swift.yml
+44 -10
@@ -18,11 +18,31 @@ on:
18 permissions:
19 contents: write
20
21 +env:
22 + # Ed depends on onde by path, so a checkout and its sibling engine move
23 + # together and CI has to reproduce that layout. Both repos are public, so no
24 + # token is needed. Point this at `development` while Ed relies on an onde API
25 + # that has not been released yet, and back at `main` once it has.
26 + ONDE_REF: main
27 +
28 jobs:
29 preflight:
23 - runs-on: ubuntu-latest
30 + # macOS rather than Linux: the release artifact is an XCFramework, the whole
31 + # toolchain is Apple, and building onde on Linux means building mistral.rs
32 + # plus a GTK/WebKit stack for the Tauri crate for no coverage anyone ships.
33 + runs-on: macos-26
34 + defaults:
35 + run:
36 + working-directory: ed
37 steps:
38 - uses: actions/checkout@v6
39 + with:
40 + path: ed
41 + - uses: actions/checkout@v6
42 + with:
43 + repository: ondeinference/onde
44 + ref: ${{ env.ONDE_REF }}
45 + path: onde
46 - name: Read Rust toolchain
47 run: |
48 rust_toolchain="$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml | head -n 1)"
@@ -39,13 +59,26 @@ jobs:
59 - run: cargo fmt --all -- --check
60 - run: cargo test --locked --workspace
61 - run: cargo clippy --locked --workspace --all-targets -- -D warnings
42 - - run: cargo publish --dry-run --locked -p ed-agent
62 + # Not --locked: packaging swaps the onde path dependency for the registry
63 + # one, which is a lockfile change by definition. This step is also the
64 + # gate that catches publishing Ed before the onde version it requires.
65 + - run: cargo publish --dry-run -p ed-agent
66
67 build-and-release:
68 needs: preflight
69 runs-on: macos-26
70 + defaults:
71 + run:
72 + working-directory: ed
73 steps:
74 - uses: actions/checkout@v6
75 + with:
76 + path: ed
77 + - uses: actions/checkout@v6
78 + with:
79 + repository: ondeinference/onde
80 + ref: ${{ env.ONDE_REF }}
81 + path: onde
82 - name: Read Rust toolchain
83 run: |
84 rust_toolchain="$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml | head -n 1)"
@@ -85,10 +118,10 @@ jobs:
118 with:
119 name: ed-swift-xcframework
120 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
121 + ed/dist/swift/EdFramework.xcframework.zip
122 + ed/dist/swift/EdFramework.checksum.txt
123 + ed/dist/swift/version.txt
124 + ed/dist/swift/Package/Sources/Ed/ed_agent_ffi.swift
125 - name: Create GitHub release
126 if: startsWith(github.ref, 'refs/tags/') || inputs.publish
127 uses: softprops/action-gh-release@v3
@@ -97,8 +130,8 @@ jobs:
130 name: ${{ steps.release.outputs.release_tag }}
131 generate_release_notes: true
132 files: |
100 - dist/swift/EdFramework.xcframework.zip
101 - dist/swift/EdFramework.checksum.txt
133 + ed/dist/swift/EdFramework.xcframework.zip
134 + ed/dist/swift/EdFramework.checksum.txt
135 - name: Checkout onde-ed-swift
136 if: startsWith(github.ref, 'refs/tags/') || inputs.publish
137 uses: actions/checkout@v6
@@ -108,6 +141,7 @@ jobs:
141 path: onde-ed-swift
142 - name: Update Swift package
143 if: startsWith(github.ref, 'refs/tags/') || inputs.publish
144 + working-directory: ${{ github.workspace }}
145 env:
146 CHECKSUM: ${{ steps.meta.outputs.checksum }}
147 RELEASE_URL: https://github.com/${{ github.repository }}/releases/download/${{ steps.release.outputs.release_tag }}/EdFramework.xcframework.zip
@@ -122,13 +156,13 @@ jobs:
156 raise RuntimeError("Package.swift release constants were not found")
157 package.write_text(source)
158 PY
125 - cp dist/swift/Package/Sources/Ed/ed_agent_ffi.swift onde-ed-swift/Sources/Ed/ed_agent_ffi.swift
159 + cp ed/dist/swift/Package/Sources/Ed/ed_agent_ffi.swift onde-ed-swift/Sources/Ed/ed_agent_ffi.swift
160 - name: Commit and tag Swift package
161 if: startsWith(github.ref, 'refs/tags/') || inputs.publish
162 + working-directory: ${{ github.workspace }}/onde-ed-swift
163 env:
164 VERSION: ${{ steps.release.outputs.release_tag }}
165 run: |
131 - cd onde-ed-swift
166 git config user.name "github-actions[bot]"
167 git config user.email "github-actions[bot]@users.noreply.github.com"
168 git add Package.swift Sources/Ed/ed_agent_ffi.swift
Cargo.toml
+1 -1
@@ -10,7 +10,7 @@ edition = "2021"
10 rust-version = "1.94"
11 license = "MIT OR Apache-2.0"
12 authors = ["Splitfire AB"]
13 -repository = "https://github.com/ondeinference/onde-ed"
13 +repository = "https://github.com/ondeinference/ed"
14 homepage = "https://ondeinference.com"
15
16 [workspace.dependencies]
crates/ed-agent-ffi/scripts/tvos_chkstk.s
+15
@@ -1,3 +1,18 @@
1 +// A no-op `___chkstk_darwin`.
2 +//
3 +// Rust emits calls to this stack-probe helper on Apple targets, and linking the
4 +// staticlib for the embedded ones fails with an undefined symbol unless
5 +// something defines it. Probing exists to fault deterministically when a frame
6 +// runs off the end of the stack, so a stub changes nothing for code that stays
7 +// within its stack and turns an overflow into corruption rather than a trap.
8 +// That trade is why this is a workaround and not a design.
9 +//
10 +// build.rs compiles it for iOS, tvOS, visionOS, and watchOS. If a link ever
11 +// fails here with a duplicate symbol rather than a missing one, that means the
12 +// toolchain now provides the real thing for that target and it should come off
13 +// the list in build.rs. Worth rechecking whenever Xcode or the pinned nightly
14 +// moves, because the day every target defines it, this file can go.
15 +
16 .text
17 .globl ___chkstk_darwin
18 .p2align 2
crates/ed-agent-ffi/src/lib.rs
+33 -4
@@ -40,6 +40,15 @@ impl From<FfiToolRisk> for ToolRisk {
40 }
41 }
42
43 +impl From<ToolRisk> for FfiToolRisk {
44 + fn from(value: ToolRisk) -> Self {
45 + match value {
46 + ToolRisk::ReadOnly => Self::ReadOnly,
47 + ToolRisk::Mutating => Self::Mutating,
48 + }
49 + }
50 +}
51 +
52 #[derive(Debug, Clone, uniffi::Record)]
53 pub struct FfiToolDefinition {
54 pub name: String,
@@ -65,6 +74,26 @@ impl From<&ToolCall> for FfiToolCall {
74 }
75 }
76
77 +/// A tool call waiting on the user, with the risk that made it wait.
78 +///
79 +/// The risk travels with the call because it is the reason an approval sheet
80 +/// is on screen at all: a host that only received the call would have to look
81 +/// the tool up again to tell the user whether it is about to change anything.
82 +#[derive(Debug, Clone, uniffi::Record)]
83 +pub struct FfiApprovalRequest {
84 + pub call: FfiToolCall,
85 + pub risk: FfiToolRisk,
86 +}
87 +
88 +impl From<&ApprovalRequest> for FfiApprovalRequest {
89 + fn from(value: &ApprovalRequest) -> Self {
90 + Self {
91 + call: (&value.call).into(),
92 + risk: value.risk.into(),
93 + }
94 + }
95 +}
96 +
97 #[derive(Debug, Clone, uniffi::Record)]
98 pub struct FfiToolOutput {
99 pub content: String,
@@ -298,14 +327,14 @@ pub trait FfiToolExecutor: Send + Sync {
327 #[uniffi::export(callback_interface)]
328 #[async_trait]
329 pub trait FfiApprovalHandler: Send + Sync {
301 - async fn approve(&self, call: FfiToolCall) -> FfiApprovalDecision;
330 + async fn approve(&self, request: FfiApprovalRequest) -> FfiApprovalDecision;
331 }
332
333 #[uniffi::export(callback_interface)]
334 pub trait FfiEventListener: Send + Sync {
335 fn status_changed(&self, update: FfiStatusUpdate);
336 fn tool_requested(&self, call: FfiToolCall);
308 - fn approval_requested(&self, call: FfiToolCall);
337 + fn approval_requested(&self, request: FfiApprovalRequest);
338 fn tool_started(&self, call: FfiToolCall);
339 fn tool_finished(&self, tool_call_id: String, content: String, is_error: bool);
340 fn agent_replied(&self, reply: FfiAgentReply);
@@ -333,7 +362,7 @@ struct ForeignApprovals(Arc<dyn FfiApprovalHandler>);
362 #[async_trait]
363 impl ApprovalHandler for ForeignApprovals {
364 async fn approve(&self, request: ApprovalRequest) -> ApprovalDecision {
336 - self.0.approve((&request.call).into()).await.into()
365 + self.0.approve((&request).into()).await.into()
366 }
367 }
368
@@ -354,7 +383,7 @@ impl EventSink for FfiSink {
383 }
384
385 fn approval_requested(&self, request: &ApprovalRequest) {
357 - self.0.approval_requested((&request.call).into());
386 + self.0.approval_requested(request.into());
387 }
388
389 fn tool_started(&self, call: &ToolCall) {
crates/ed-agent-tauri/README.md
+15 -4
@@ -15,15 +15,26 @@ let ed = Ed::with_sink(TauriSink::new(app.handle().clone()));
15 app.manage(EdState::new(ed));
16 ```
17
18 -Then register `chat_get_status`, `chat_get_history`, `chat_clear_history`, and
19 -`chat_send_message` on the builder's `invoke_handler`. Two events reach the
20 -webview: `chat_status_changed` and `chat_reply`.
18 +Then register `chat_get_status`, `chat_get_history`, `chat_clear_history`,
19 +`chat_send_message`, `chat_run`, and `chat_cancel` on the builder's
20 +`invoke_handler`.
21 +
22 +These events reach the webview: `chat_status_changed`, `chat_reply`,
23 +`chat_tool_requested`, `chat_approval_requested`, `chat_tool_started`,
24 +`chat_tool_finished`, `chat_agent_reply`, and `chat_warning`.
25
26 Loading a model is deliberately not a command here — which model, and from
27 where, is an application decision. Call `Ed::load` or `Ed::load_with` from your
28 own command.
29
30 +Tool calling needs both halves from you. Build the agent with `Ed::with_agent`
31 +to pass a `ToolExecutor` and an `ApprovalHandler`; `Ed::with_sink` rejects every
32 +tool and denies every approval. Approvals are not a command either: answering
33 +one means a webview round trip inside an awaited Rust call, and hosts differ
34 +enough about how to wire that up that this crate emits
35 +`chat_approval_requested` and leaves the answering to you.
36 +
37 ## Copyright
38
39 © 2026 [Splitfire AB](https://5mb.app) ([Onde Inference](https://ondeinference.com)).
29 -Licensed under Apache-2.0.
40 +Licensed under MIT or Apache-2.0.
crates/ed-agent-tauri/src/commands.rs
+29 -1
@@ -1,6 +1,6 @@
1 //! The commands a webview invokes.
2
3 -use ed_agent::{ChatMessage, ChatReply, Ed, EngineInfo};
3 +use ed_agent::{AgentReply, ChatMessage, ChatReply, Ed, EngineInfo};
4 use tauri::State;
5
6 use crate::sink::TauriSink;
@@ -60,3 +60,31 @@ pub async fn chat_send_message(
60 ) -> Result<ChatReply, String> {
61 Ok(state.ed.send(message).await)
62 }
63 +
64 +/// Run a full agent turn, including any tools the host registered.
65 +///
66 +/// Reports progress as it goes: `chat_tool_requested`,
67 +/// `chat_approval_requested`, `chat_tool_started`, `chat_tool_finished`, and
68 +/// finally `chat_agent_reply`. As with [`chat_send_message`], prefer the event
69 +/// over awaiting this call, since an agent turn is slower still.
70 +///
71 +/// Unlike [`chat_send_message`] this returns an `Err` on failure rather than
72 +/// folding it into the payload: an agent turn has failure modes a chat reply
73 +/// doesn't (a model that can't call tools, a cancelled turn, a spent round
74 +/// budget), and flattening them all into an error string would throw away the
75 +/// distinction the frontend needs to respond to.
76 +#[tauri::command]
77 +pub async fn chat_run(state: State<'_, EdState>, message: String) -> Result<AgentReply, String> {
78 + state
79 + .ed
80 + .run(message)
81 + .await
82 + .map_err(|error| error.to_string())
83 +}
84 +
85 +/// Cancel the agent turn in progress, if there is one.
86 +#[tauri::command]
87 +pub async fn chat_cancel(state: State<'_, EdState>) -> Result<(), String> {
88 + state.ed.cancel();
89 + Ok(())
90 +}
crates/ed-agent-tauri/src/events.rs
+33
@@ -12,6 +12,39 @@ pub const EVENT_CHAT_STATUS_CHANGED: &str = "chat_status_changed";
12 /// Emitted once an inference turn completes, successfully or not.
13 pub const EVENT_CHAT_REPLY: &str = "chat_reply";
14
15 +/// Emitted with a [`ToolCall`](ed_agent::ToolCall) when the model asks for a
16 +/// tool, before any approval or execution.
17 +pub const EVENT_CHAT_TOOL_REQUESTED: &str = "chat_tool_requested";
18 +
19 +/// Emitted with an [`ApprovalRequest`](ed_agent::ApprovalRequest) when a
20 +/// mutating tool is waiting on the user.
21 +///
22 +/// This is a notification, not the approval itself. Ed asks the
23 +/// [`ApprovalHandler`](ed_agent::ApprovalHandler) the host passed to
24 +/// [`Ed::with_agent`](ed_agent::Ed::with_agent) for the decision; this event
25 +/// exists so the webview can put a sheet on screen while that call is pending.
26 +pub const EVENT_CHAT_APPROVAL_REQUESTED: &str = "chat_approval_requested";
27 +
28 +/// Emitted with a [`ToolCall`](ed_agent::ToolCall) once a tool is approved and
29 +/// about to run.
30 +pub const EVENT_CHAT_TOOL_STARTED: &str = "chat_tool_started";
31 +
32 +/// Emitted with a [`ToolExecutionResult`](ed_agent::ToolExecutionResult) when a
33 +/// tool finishes, whether it succeeded or failed.
34 +pub const EVENT_CHAT_TOOL_FINISHED: &str = "chat_tool_finished";
35 +
36 +/// Emitted with an [`AgentReply`](ed_agent::AgentReply) when a full agent turn
37 +/// completes.
38 +///
39 +/// Distinct from [`EVENT_CHAT_REPLY`], which covers the single-shot
40 +/// [`Ed::send`](ed_agent::Ed::send) path. A turn reports one or the other,
41 +/// never both.
42 +pub const EVENT_CHAT_AGENT_REPLY: &str = "chat_agent_reply";
43 +
44 +/// Emitted with a string when Ed has something the user should see but the turn
45 +/// is still viable, such as an unverified model or a spent tool-round budget.
46 +pub const EVENT_CHAT_WARNING: &str = "chat_warning";
47 +
48 /// Payload for [`EVENT_CHAT_STATUS_CHANGED`].
49 ///
50 /// `status` serialises to `"unloaded"`, `"loading"`, `"ready"`, `"generating"`,
crates/ed-agent-tauri/src/lib.rs
+35 -4
@@ -32,6 +32,8 @@
32 //! ed_agent_tauri::chat_get_history,
33 //! ed_agent_tauri::chat_clear_history,
34 //! ed_agent_tauri::chat_send_message,
35 +//! ed_agent_tauri::chat_run,
36 +//! ed_agent_tauri::chat_cancel,
37 //! ])
38 //! .run(tauri::generate_context!())
39 //! .expect("run");
@@ -41,13 +43,37 @@
43 //! where, is an application decision. Call [`Ed::load`](ed_agent::Ed::load)
44 //! from your own command or during setup.
45 //!
46 +//! # Tools
47 +//!
48 +//! Tool calling works through this crate, but the host owns both halves of it.
49 +//! Build the agent with [`Ed::with_agent`](ed_agent::Ed::with_agent) to supply
50 +//! a [`ToolExecutor`](ed_agent::ToolExecutor) and an
51 +//! [`ApprovalHandler`](ed_agent::ApprovalHandler); the default `Ed::with_sink`
52 +//! rejects every tool and denies every approval, which is the right default but
53 +//! not a working agent.
54 +//!
55 +//! Approvals are deliberately not a command. Answering one means a webview
56 +//! round trip in the middle of an awaited Rust call, and how a host wants to
57 +//! wire that up (a channel, a shared map, a modal it blocks on) differs enough
58 +//! that guessing here would be worse than leaving it. What this crate does give
59 +//! you is [`EVENT_CHAT_APPROVAL_REQUESTED`], so the sheet can go up at the
60 +//! right moment.
61 +//!
62 //! # Events
63 //!
46 -//! Two events reach the webview, named exactly as the existing applications
47 -//! already emit them so a frontend needs no change:
64 +//! The first two are named exactly as the existing applications already emit
65 +//! them, so a frontend needs no change:
66 //!
67 //! - [`EVENT_CHAT_STATUS_CHANGED`] with a [`ChatStatusPayload`]
68 //! - [`EVENT_CHAT_REPLY`] with a [`ChatReply`](ed_agent::ChatReply)
69 +//! - [`EVENT_CHAT_TOOL_REQUESTED`] with a [`ToolCall`](ed_agent::ToolCall)
70 +//! - [`EVENT_CHAT_APPROVAL_REQUESTED`] with an
71 +//! [`ApprovalRequest`](ed_agent::ApprovalRequest)
72 +//! - [`EVENT_CHAT_TOOL_STARTED`] with a [`ToolCall`](ed_agent::ToolCall)
73 +//! - [`EVENT_CHAT_TOOL_FINISHED`] with a
74 +//! [`ToolExecutionResult`](ed_agent::ToolExecutionResult)
75 +//! - [`EVENT_CHAT_AGENT_REPLY`] with an [`AgentReply`](ed_agent::AgentReply)
76 +//! - [`EVENT_CHAT_WARNING`] with a string
77
78 #![forbid(unsafe_code)]
79
@@ -56,7 +82,12 @@ mod events;
82 mod sink;
83
84 pub use commands::{
59 - chat_clear_history, chat_get_history, chat_get_status, chat_send_message, EdState,
85 + chat_cancel, chat_clear_history, chat_get_history, chat_get_status, chat_run,
86 + chat_send_message, EdState,
87 +};
88 +pub use events::{
89 + ChatStatusPayload, EVENT_CHAT_AGENT_REPLY, EVENT_CHAT_APPROVAL_REQUESTED, EVENT_CHAT_REPLY,
90 + EVENT_CHAT_STATUS_CHANGED, EVENT_CHAT_TOOL_FINISHED, EVENT_CHAT_TOOL_REQUESTED,
91 + EVENT_CHAT_TOOL_STARTED, EVENT_CHAT_WARNING,
92 };
61 -pub use events::{ChatStatusPayload, EVENT_CHAT_REPLY, EVENT_CHAT_STATUS_CHANGED};
93 pub use sink::TauriSink;
crates/ed-agent-tauri/src/sink.rs
+50 -15
@@ -1,12 +1,19 @@
1 //! Bridges Ed's notifications onto Tauri's event bus.
2
3 -use ed_agent::{ChatReply, EngineStatus, StatusSink};
3 +use ed_agent::{
4 + AgentReply, ApprovalRequest, ChatReply, EngineStatus, EventSink, ToolCall, ToolExecutionResult,
5 +};
6 use log::warn;
7 +use serde::Serialize;
8 use tauri::{AppHandle, Emitter};
9
7 -use crate::events::{ChatStatusPayload, EVENT_CHAT_REPLY, EVENT_CHAT_STATUS_CHANGED};
10 +use crate::events::{
11 + ChatStatusPayload, EVENT_CHAT_AGENT_REPLY, EVENT_CHAT_APPROVAL_REQUESTED, EVENT_CHAT_REPLY,
12 + EVENT_CHAT_STATUS_CHANGED, EVENT_CHAT_TOOL_FINISHED, EVENT_CHAT_TOOL_REQUESTED,
13 + EVENT_CHAT_TOOL_STARTED, EVENT_CHAT_WARNING,
14 +};
15
9 -/// A [`StatusSink`] that emits to the webview.
16 +/// An [`EventSink`] that emits to the webview.
17 ///
18 /// Emission failures are logged rather than propagated: a closing window
19 /// dropping its listeners is normal, and an inference turn that already
@@ -20,23 +27,51 @@ impl TauriSink {
27 pub fn new(app: AppHandle) -> Self {
28 Self { app }
29 }
30 +
31 + fn emit(&self, event: &str, payload: impl Serialize + Clone) {
32 + if let Err(err) = self.app.emit(event, payload) {
33 + warn!("ed-tauri: could not emit {event}: {err}");
34 + }
35 + }
36 }
37
25 -impl StatusSink for TauriSink {
38 +impl EventSink for TauriSink {
39 fn status_changed(&self, status: EngineStatus, model_name: Option<&str>, error: Option<&str>) {
27 - let payload = ChatStatusPayload {
28 - status,
29 - model_name: model_name.map(str::to_owned),
30 - error: error.map(str::to_owned),
31 - };
32 - if let Err(err) = self.app.emit(EVENT_CHAT_STATUS_CHANGED, payload) {
33 - warn!("ed-tauri: could not emit {EVENT_CHAT_STATUS_CHANGED}: {err}");
34 - }
40 + self.emit(
41 + EVENT_CHAT_STATUS_CHANGED,
42 + ChatStatusPayload {
43 + status,
44 + model_name: model_name.map(str::to_owned),
45 + error: error.map(str::to_owned),
46 + },
47 + );
48 }
49
50 fn replied(&self, reply: &ChatReply) {
38 - if let Err(err) = self.app.emit(EVENT_CHAT_REPLY, reply) {
39 - warn!("ed-tauri: could not emit {EVENT_CHAT_REPLY}: {err}");
40 - }
51 + self.emit(EVENT_CHAT_REPLY, reply);
52 + }
53 +
54 + fn tool_requested(&self, call: &ToolCall) {
55 + self.emit(EVENT_CHAT_TOOL_REQUESTED, call);
56 + }
57 +
58 + fn approval_requested(&self, request: &ApprovalRequest) {
59 + self.emit(EVENT_CHAT_APPROVAL_REQUESTED, request);
60 + }
61 +
62 + fn tool_started(&self, call: &ToolCall) {
63 + self.emit(EVENT_CHAT_TOOL_STARTED, call);
64 + }
65 +
66 + fn tool_finished(&self, result: &ToolExecutionResult) {
67 + self.emit(EVENT_CHAT_TOOL_FINISHED, result);
68 + }
69 +
70 + fn agent_replied(&self, reply: &AgentReply) {
71 + self.emit(EVENT_CHAT_AGENT_REPLY, reply);
72 + }
73 +
74 + fn warning(&self, message: &str) {
75 + self.emit(EVENT_CHAT_WARNING, message);
76 }
77 }
crates/ed-agent/src/ed.rs
+127 -31
@@ -69,31 +69,42 @@ impl<S: EventSink> Ed<S> {
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 - }
72 + Self::with_agent_and_app_id(sink, executor, approvals, config, None)
73 }
74
75 /// Associate telemetry with an Onde app id. See [`ChatEngine::with_app_id`].
76 pub fn with_app_id(sink: S, onde_app_id: Option<String>) -> Self {
77 + Self::with_agent_and_app_id(
78 + sink,
79 + Arc::new(RejectingExecutor),
80 + Arc::new(DenyApprovals),
81 + AgentConfig::default(),
82 + onde_app_id,
83 + )
84 + }
85 +
86 + /// The full constructor: tools, approvals, and an Onde app id together.
87 + ///
88 + /// The narrower constructors each fill in defaults for what they don't
89 + /// take. Use this one when you need both host tools and telemetry, which
90 + /// the others cannot express between them.
91 + pub fn with_agent_and_app_id(
92 + sink: S,
93 + executor: Arc<dyn ToolExecutor>,
94 + approvals: Arc<dyn ApprovalHandler>,
95 + config: AgentConfig,
96 + onde_app_id: Option<String>,
97 + ) -> Self {
98 Self {
99 engine: ChatEngine::with_app_id(onde_app_id),
100 sink,
101 tools: tokio::sync::RwLock::new(HashMap::new()),
91 - executor: Arc::new(RejectingExecutor),
92 - approvals: Arc::new(DenyApprovals),
102 + executor,
103 + approvals,
104 session_grants: tokio::sync::Mutex::new(HashSet::new()),
105 turn: tokio::sync::Mutex::new(()),
106 cancelled: AtomicBool::new(false),
96 - agent_config: AgentConfig::default(),
107 + agent_config: config.validated(),
108 }
109 }
110
@@ -202,7 +213,7 @@ impl<S: EventSink> Ed<S> {
213 /// Never returns an error: a failure is reported in
214 /// [`ChatReply::error`] so a caller has one shape to render either way.
215 /// The reply also goes to the sink, so a host can render it from an event
205 - /// instead of awaiting this call — see [`StatusSink::replied`].
216 + /// instead of awaiting this call — see [`EventSink::replied`].
217 pub async fn send(&self, message: impl Into<String>) -> ChatReply {
218 let reply = match self.engine.send_message(message).await {
219 Ok(result) => ChatReply::ok(result.text, result.duration_display),
@@ -313,27 +324,45 @@ impl<S: EventSink> Ed<S> {
324 }
325
326 /// Run a complete local agent turn, including any requested host tools.
327 + ///
328 + /// With no tools registered this is one inference call. Otherwise Ed
329 + /// alternates between the model and the host's executor until the model
330 + /// answers in text, or until
331 + /// [`max_tool_rounds`](AgentConfig::max_tool_rounds) is spent, whichever
332 + /// comes first. Reaching the budget is reported to the sink as a warning,
333 + /// and yields [`AgentError::ToolRoundsExhausted`] if there is no usable
334 + /// text by then.
335 + ///
336 + /// Reports [`EventSink::agent_replied`] on success. It does *not* report
337 + /// [`EventSink::replied`]; that belongs to [`send`](Ed::send).
338 pub async fn run(&self, message: impl Into<String>) -> Result<AgentReply, AgentError> {
339 let _turn = self.turn.lock().await;
340 self.cancelled.store(false, Ordering::Release);
341
342 let registered = self.tools.read().await.clone();
343 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),
344 + // Deliberately not routed through `send`: that reports a
345 + // `ChatReply` to the sink, and a host listening for both events
346 + // would render this turn's answer twice. An agent turn reports
347 + // `agent_replied` and nothing else.
348 + let result =
349 + self.engine
350 + .send_message(message)
351 + .await
352 + .map_err(|error| AgentError::Inference {
353 + reason: error.to_string(),
354 + })?;
355 + if result.text.trim().is_empty() {
356 + return Err(AgentError::EmptyReply);
357 + }
358 + let agent_reply = AgentReply {
359 + text: result.text,
360 + duration_seconds: result.duration_secs,
361 + duration: result.duration_display,
362 + tool_rounds: 0,
363 };
364 + self.sink.agent_replied(&agent_reply);
365 + return Ok(agent_reply);
366 }
367
368 match self.engine.tool_calling_support().await {
@@ -363,8 +392,38 @@ impl<S: EventSink> Ed<S> {
392 })?;
393 let mut duration_seconds = result.duration_secs;
394 let mut rounds = 0_u8;
395 + let max_rounds = self.agent_config.max_tool_rounds;
396 + let mut exhausted = false;
397
398 while !result.tool_calls.is_empty() {
399 + if !self.agent_config.allows_round(rounds) {
400 + // The budget only stops Ed *offering* tools; it cannot stop the
401 + // model asking. `onde` parses tool calls out of every response
402 + // whether or not tools were sent, so a model that keeps
403 + // emitting tool-call syntax would loop here until `rounds`
404 + // overflowed and wrapped back under the limit. This is the
405 + // loop's only hard exit.
406 + //
407 + // The model's last turn is already in history carrying these
408 + // calls. Leaving them unanswered would make the next turn's
409 + // message sequence invalid, so close them out the way
410 + // cancelling does.
411 + let unanswered = result
412 + .tool_calls
413 + .iter()
414 + .map(|pending| ToolResult {
415 + tool_call_id: pending.id.clone(),
416 + content: "tool was not executed: the agent ran out of tool rounds"
417 + .to_string(),
418 + })
419 + .collect();
420 + self.engine.record_tool_results(unanswered).await;
421 + self.sink.warning(&format!(
422 + "the agent stopped after {max_rounds} tool rounds; the model kept requesting tools"
423 + ));
424 + exhausted = true;
425 + break;
426 + }
427 rounds += 1;
428 let mut tool_results = Vec::with_capacity(result.tool_calls.len());
429
@@ -401,7 +460,7 @@ impl<S: EventSink> Ed<S> {
460 return Err(AgentError::Cancelled);
461 }
462
404 - let next_tools = if rounds < self.agent_config.max_tool_rounds {
463 + let next_tools = if self.agent_config.allows_round(rounds) {
464 Some(definitions.as_slice())
465 } else {
466 None
@@ -417,7 +476,11 @@ impl<S: EventSink> Ed<S> {
476 }
477
478 if result.text.trim().is_empty() {
420 - return Err(AgentError::EmptyReply);
479 + return Err(if exhausted {
480 + AgentError::ToolRoundsExhausted { rounds: max_rounds }
481 + } else {
482 + AgentError::EmptyReply
483 + });
484 }
485 let reply = AgentReply {
486 text: result.text,
@@ -448,8 +511,14 @@ impl<S: EventSink> Ed<S> {
511 ///
512 /// Leaves the model loaded — this starts a new conversation, it doesn't
513 /// tear down the engine.
514 + ///
515 + /// Also drops every `AllowForSession` grant. The user approved those
516 + /// mutating tools for a conversation, and this ends the conversation;
517 + /// carrying the grants into the next one would keep consent alive past the
518 + /// thing it was given for.
519 pub async fn clear_history(&self) -> usize {
520 let cleared = self.engine.clear_history().await;
521 + self.session_grants.lock().await.clear();
522 info!("ed: cleared {cleared} messages");
523 cleared
524 }
@@ -692,6 +761,33 @@ mod tests {
761 assert!(ed.session_grants.lock().await.contains("save_note"));
762 }
763
764 + #[tokio::test]
765 + async fn clearing_the_conversation_revokes_session_grants() {
766 + // The grant was given for a conversation. Clearing the history ends
767 + // that conversation, so the next one has to ask again.
768 + let executor = Arc::new(CountingExecutor(AtomicUsize::new(0)));
769 + let ed = Ed::with_agent(
770 + NoopSink,
771 + executor.clone(),
772 + Arc::new(FixedApproval(ApprovalDecision::AllowForSession)),
773 + AgentConfig::default(),
774 + );
775 + ed.register_tool(AgentToolDefinition::mutating(
776 + "save_note",
777 + "Save a note",
778 + r#"{"type":"object"}"#,
779 + ))
780 + .await
781 + .unwrap();
782 + let tools = ed.tools.read().await.clone();
783 + ed.execute_tool_call(&call("save_note"), &tools).await;
784 + assert!(ed.session_grants.lock().await.contains("save_note"));
785 +
786 + ed.clear_history().await;
787 +
788 + assert!(ed.session_grants.lock().await.is_empty());
789 + }
790 +
791 #[tokio::test]
792 async fn unknown_tools_return_an_error_result_instead_of_executing() {
793 let executor = Arc::new(CountingExecutor(AtomicUsize::new(0)));
crates/ed-agent/src/lib.rs
+8 -4
@@ -20,10 +20,14 @@
20 //! # Platforms
21 //!
22 //! Ed has no `cfg(target_os)` gates. `onde` already splits real and fallback
23 -//! implementations internally, so this crate compiles everywhere and simply
24 -//! surfaces `onde`'s error on a platform it can't run inference on. Do not add
25 -//! platform gates here, and be careful about adding them in a host: a gate
26 -//! narrower than `onde`'s own support list silently disables chat on a
23 +//! implementations internally, so this crate compiles on every platform `onde`
24 +//! supports (macOS, iOS, tvOS, visionOS, watchOS, Windows, Linux, Android) and
25 +//! simply surfaces `onde`'s error where it can't run inference. Outside that
26 +//! list `onde` falls back to a stub whose signatures differ, so Ed does not
27 +//! build there either.
28 +//!
29 +//! Do not add platform gates here, and be careful about adding them in a host:
30 +//! a gate narrower than `onde`'s own support list silently disables chat on a
31 //! platform that would have worked.
32 //!
33 //! # Example
crates/ed-agent/src/tool.rs
+43
@@ -157,6 +157,17 @@ impl AgentConfig {
157 max_tool_output_chars: self.max_tool_output_chars.max(1),
158 }
159 }
160 +
161 + /// Whether another tool round may run, given how many already have.
162 + ///
163 + /// Asked twice per round, and the pair is what makes the loop terminate:
164 + /// once to decide whether to offer tools to the model again, and once at
165 + /// the top of the next round to decide whether to run it at all. If only
166 + /// the first check existed, a model that requested tools it was never
167 + /// offered would keep the loop alive indefinitely.
168 + pub(crate) fn allows_round(&self, rounds: u8) -> bool {
169 + rounds < self.max_tool_rounds
170 + }
171 }
172
173 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -179,6 +190,8 @@ pub enum AgentError {
190 Cancelled,
191 #[error("the model returned an empty final reply")]
192 EmptyReply,
193 + #[error("the agent stopped after {rounds} tool rounds without a final reply")]
194 + ToolRoundsExhausted { rounds: u8 },
195 #[error("inference failed: {reason}")]
196 Inference { reason: String },
197 }
@@ -213,6 +226,36 @@ mod tests {
226 );
227 }
228
229 + #[test]
230 + fn a_validated_budget_always_allows_at_least_one_round() {
231 + // A zero budget would mean registering tools quietly disabled them:
232 + // the model would be offered tools it could never be allowed to use.
233 + let config = AgentConfig {
234 + max_tool_rounds: 0,
235 + max_tool_output_chars: 0,
236 + }
237 + .validated();
238 +
239 + assert!(config.allows_round(0));
240 + assert!(!config.allows_round(1));
241 + assert_eq!(config.max_tool_output_chars, 1);
242 + }
243 +
244 + #[test]
245 + fn the_round_budget_closes_at_the_limit_and_stays_closed() {
246 + let config = AgentConfig {
247 + max_tool_rounds: 3,
248 + max_tool_output_chars: 100,
249 + }
250 + .validated();
251 +
252 + assert!(config.allows_round(2));
253 + assert!(!config.allows_round(3));
254 + // Past the limit too: `rounds` is only ever compared, never trusted to
255 + // land exactly on the boundary.
256 + assert!(!config.allows_round(u8::MAX));
257 + }
258 +
259 #[test]
260 fn truncation_is_character_safe_and_explicit() {
261 assert_eq!(