localharness 0.42.0

Agents that own themselves: one Rust crate that's both an agent SDK (streaming, tools, hooks, policies, triggers, MCP) and a wallet-owning, self-sovereign agent that runs in the browser.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
//! Host-side custom tools.
//!
//! A `Tool` is anything that exposes a JSON-schema-described entry point and
//! produces JSON back. `ToolRunner` registers tools by name and dispatches
//! calls from the harness. The optional `ToolContext` gives tools a handle
//! back to the live connection so they can stream out-of-band messages.

use std::collections::HashMap;
use std::sync::Arc;

use arc_swap::ArcSwapOption;
use async_trait::async_trait;
use parking_lot::RwLock;
use serde_json::Value;

use crate::connections::Connection;
use crate::error::{Error, Result};
use crate::runtime::MaybeSendSync;
use crate::types::{ToolCall, ToolResult};

// =============================================================================
// Tool trait
// =============================================================================

/// A named, schema-described function the model can call.
///
/// Implement this trait to expose custom logic to the agent. Register
/// instances via [`ToolRunner::register`] or [`AgentConfig::with_tool`].
///
/// [`AgentConfig::with_tool`]: crate::AgentConfig::with_tool
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
pub trait Tool: MaybeSendSync {
    /// Unique wire name the model uses to invoke this tool.
    fn name(&self) -> &str;
    /// Human-readable description shown to the model.
    fn description(&self) -> &str;
    /// JSON Schema describing the expected arguments.
    fn input_schema(&self) -> Value;
    /// Run the tool with the given arguments and return a JSON result.
    async fn execute(&self, args: Value, ctx: Option<Arc<ToolContext>>) -> Result<Value>;
}

// =============================================================================
// Tool context
// =============================================================================

/// Runtime context available to tools during execution.
///
/// Provides access to the live connection (for sending out-of-band messages)
/// and a per-session key-value store for cross-tool state.
pub struct ToolContext {
    connection: Arc<dyn Connection>,
    state: RwLock<HashMap<String, Value>>,
}

impl ToolContext {
    /// Create a new context bound to the given connection.
    pub fn new(connection: Arc<dyn Connection>) -> Self {
        Self {
            connection,
            state: RwLock::new(HashMap::new()),
        }
    }

    /// The backend-assigned conversation identifier.
    pub fn conversation_id(&self) -> &str {
        self.connection.conversation_id()
    }

    /// Whether the agent is currently idle (no turn in flight).
    pub fn is_idle(&self) -> bool {
        self.connection.is_idle()
    }

    /// Send an out-of-band trigger message into the agent.
    pub async fn send(&self, message: impl Into<String>) -> Result<()> {
        self.connection.send_trigger(message.into()).await
    }

    /// Read a value from the per-session state store.
    pub fn get_state(&self, key: &str) -> Option<Value> {
        self.state.read().get(key).cloned()
    }

    /// Write a value into the per-session state store.
    pub fn set_state(&self, key: impl Into<String>, value: Value) {
        self.state.write().insert(key.into(), value);
    }
}

// =============================================================================
// Runner
// =============================================================================

/// Registry that maps tool names to implementations and dispatches calls.
pub struct ToolRunner {
    tools: RwLock<HashMap<String, Arc<dyn Tool>>>,
    context: ArcSwapOption<ToolContext>,
}

impl Default for ToolRunner {
    fn default() -> Self {
        Self {
            tools: RwLock::new(HashMap::new()),
            context: ArcSwapOption::from(None),
        }
    }
}

impl ToolRunner {
    /// Create an empty tool runner with no registered tools.
    pub fn new() -> Self {
        Self::default()
    }

    /// Register a tool by name. Overwrites any existing tool with the same name.
    pub fn register(&self, tool: Arc<dyn Tool>) {
        let name = tool.name().to_string();
        self.tools.write().insert(name, tool);
    }

    /// Set the shared context passed to tools on each execution.
    pub fn set_context(&self, ctx: Arc<ToolContext>) {
        self.context.store(Some(ctx));
    }

    /// Remove the shared context (tools will receive `None`).
    pub fn clear_context(&self) {
        self.context.store(None);
    }

    /// List the names of all registered tools.
    pub fn names(&self) -> Vec<String> {
        self.tools.read().keys().cloned().collect()
    }

    /// Snapshot every registered tool as `Arc<dyn Tool>`. Cheap clone —
    /// the `Arc`s share their backing data.
    pub fn iter_tools(&self) -> Vec<Arc<dyn Tool>> {
        self.tools.read().values().cloned().collect()
    }

    /// Execute a tool by name with the given JSON arguments.
    pub async fn execute(&self, name: &str, args: Value) -> Result<Value> {
        let tool = self
            .tools
            .read()
            .get(name)
            .cloned()
            .ok_or_else(|| Error::ToolNotFound {
                name: name.to_string(),
            })?;
        let ctx = self.context.load_full();
        tool.execute(args, ctx).await
    }

    /// Execute a batch of tool calls and collect their results.
    pub async fn process_tool_calls(&self, calls: Vec<ToolCall>) -> Vec<ToolResult> {
        let mut results = Vec::with_capacity(calls.len());
        for call in calls {
            match self.execute(&call.name, call.args.clone()).await {
                Ok(value) => results.push(ToolResult::ok(call.name, call.id, value)),
                Err(e) => results.push(ToolResult::err(call.name, call.id, e.to_string())),
            }
        }
        results
    }
}

// =============================================================================
// Builder helper for ad-hoc closure-based tools
// =============================================================================

// On wasm32 the future doesn't need `Send` (single-threaded executor)
// and the closure doesn't either. Keep the native signature unchanged.
#[cfg(not(target_arch = "wasm32"))]
type ToolFuture = futures_util::future::BoxFuture<'static, Result<Value>>;
#[cfg(target_arch = "wasm32")]
type ToolFuture = futures_util::future::LocalBoxFuture<'static, Result<Value>>;
#[cfg(not(target_arch = "wasm32"))]
type ClosureHandler = Arc<dyn Fn(Value, Option<Arc<ToolContext>>) -> ToolFuture + Send + Sync>;
#[cfg(target_arch = "wasm32")]
type ClosureHandler = Arc<dyn Fn(Value, Option<Arc<ToolContext>>) -> ToolFuture>;

/// A `Tool` whose `execute` is an `Arc<dyn Fn>` closure. Useful for binding
/// a Rust function into the SDK without creating a dedicated type.
pub struct ClosureTool {
    name: String,
    description: String,
    schema: Value,
    handler: ClosureHandler,
}

impl ClosureTool {
    /// Build a closure-based tool from a name, description, JSON schema, and async handler.
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use localharness::ClosureTool;
    /// use serde_json::json;
    ///
    /// let tool = ClosureTool::new(
    ///     "greet",
    ///     "Say hello to someone",
    ///     json!({"type": "object", "properties": {"name": {"type": "string"}}}),
    ///     |args, _ctx| async move {
    ///         let name = args["name"].as_str().unwrap_or("world");
    ///         Ok(json!({"greeting": format!("Hello, {name}!")}))
    ///     },
    /// );
    /// ```
    #[cfg(not(target_arch = "wasm32"))]
    pub fn new<F, Fut>(
        name: impl Into<String>,
        description: impl Into<String>,
        schema: Value,
        handler: F,
    ) -> Arc<Self>
    where
        F: Fn(Value, Option<Arc<ToolContext>>) -> Fut + Send + Sync + 'static,
        Fut: std::future::Future<Output = Result<Value>> + Send + 'static,
    {
        Arc::new(Self {
            name: name.into(),
            description: description.into(),
            schema,
            handler: Arc::new(move |a, c| Box::pin(handler(a, c))),
        })
    }
    #[cfg(target_arch = "wasm32")]
    pub fn new<F, Fut>(
        name: impl Into<String>,
        description: impl Into<String>,
        schema: Value,
        handler: F,
    ) -> Arc<Self>
    where
        F: Fn(Value, Option<Arc<ToolContext>>) -> Fut + 'static,
        Fut: std::future::Future<Output = Result<Value>> + 'static,
    {
        Arc::new(Self {
            name: name.into(),
            description: description.into(),
            schema,
            handler: Arc::new(move |a, c| Box::pin(handler(a, c))),
        })
    }

    /// Build a closure-based tool whose handler captures shared `state`.
    ///
    /// A `ClosureTool` runs its handler on *every* call, so any state the
    /// handler touches (a counter, an `Arc` resource, an observability sink)
    /// must be cloned *into the handler once* and then *again into each async
    /// body* — the awkward double-move below. `with_state` hoists that clone
    /// into the framework: you pass the shared `state` once, and your closure
    /// receives a fresh clone as its first argument on every call. The handler
    /// stays clean — no manual clone, no double-move.
    ///
    /// # Before (stateless [`ClosureTool::new`] — the double-move workaround)
    ///
    /// ```rust,no_run
    /// use std::sync::Arc;
    /// use std::sync::atomic::{AtomicU64, Ordering};
    /// use localharness::ClosureTool;
    /// use serde_json::json;
    ///
    /// let calls = Arc::new(AtomicU64::new(0));
    /// let calls_in_tool = calls.clone();              // move #1: into the closure
    /// let tool = ClosureTool::new(
    ///     "tick",
    ///     "Increment a shared counter.",
    ///     json!({"type": "object", "properties": {}}),
    ///     move |_args, _ctx| {
    ///         let calls = calls_in_tool.clone();       // move #2: into the async body
    ///         async move {
    ///             calls.fetch_add(1, Ordering::SeqCst);
    ///             Ok(json!({"count": calls.load(Ordering::SeqCst)}))
    ///         }
    ///     },
    /// );
    /// ```
    ///
    /// # After (stateful `with_state` — the framework owns the clone)
    ///
    /// ```rust,no_run
    /// use std::sync::Arc;
    /// use std::sync::atomic::{AtomicU64, Ordering};
    /// use localharness::ClosureTool;
    /// use serde_json::json;
    ///
    /// let calls = Arc::new(AtomicU64::new(0));
    /// let tool = ClosureTool::with_state(
    ///     "tick",
    ///     "Increment a shared counter.",
    ///     json!({"type": "object", "properties": {}}),
    ///     calls,                                       // shared state, passed once
    ///     |calls, _args, _ctx| async move {            // a fresh clone per call
    ///         calls.fetch_add(1, Ordering::SeqCst);
    ///         Ok(json!({"count": calls.load(Ordering::SeqCst)}))
    ///     },
    /// );
    /// ```
    #[cfg(not(target_arch = "wasm32"))]
    pub fn with_state<S, F, Fut>(
        name: impl Into<String>,
        description: impl Into<String>,
        schema: Value,
        state: S,
        f: F,
    ) -> Arc<Self>
    where
        S: Clone + Send + Sync + 'static,
        F: Fn(S, Value, Option<Arc<ToolContext>>) -> Fut + Send + Sync + 'static,
        Fut: std::future::Future<Output = Result<Value>> + Send + 'static,
    {
        Self::new(name, description, schema, move |args, ctx| {
            f(state.clone(), args, ctx)
        })
    }

    /// Build a closure-based tool whose handler captures shared `state`.
    ///
    /// See the native overload for the before/after example. On wasm32 the
    /// closure, future, and state shed their `Send + Sync` bounds (the browser
    /// executor is single-threaded), matching [`ClosureTool::new`].
    #[cfg(target_arch = "wasm32")]
    pub fn with_state<S, F, Fut>(
        name: impl Into<String>,
        description: impl Into<String>,
        schema: Value,
        state: S,
        f: F,
    ) -> Arc<Self>
    where
        S: Clone + 'static,
        F: Fn(S, Value, Option<Arc<ToolContext>>) -> Fut + 'static,
        Fut: std::future::Future<Output = Result<Value>> + 'static,
    {
        Self::new(name, description, schema, move |args, ctx| {
            f(state.clone(), args, ctx)
        })
    }
}

#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
impl Tool for ClosureTool {
    fn name(&self) -> &str {
        &self.name
    }
    fn description(&self) -> &str {
        &self.description
    }
    fn input_schema(&self) -> Value {
        self.schema.clone()
    }
    async fn execute(&self, args: Value, ctx: Option<Arc<ToolContext>>) -> Result<Value> {
        (self.handler)(args, ctx).await
    }
}

#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
    use super::*;
    use std::sync::atomic::{AtomicU64, Ordering};
    use serde_json::json;

    // `with_state` must thread the SAME shared state through every call, cloned
    // once by the framework. Build a counter tool over an `Arc<AtomicU64>`,
    // invoke it three times via the runner, and assert the mutation accumulated
    // across calls (i.e. the clone is a handle to the one counter, not a copy).
    #[tokio::test]
    async fn with_state_threads_shared_state_across_calls() {
        let counter = Arc::new(AtomicU64::new(0));

        let tool = ClosureTool::with_state(
            "tick",
            "Increment a shared counter and report the new value.",
            json!({ "type": "object", "properties": {} }),
            counter.clone(),
            |counter: Arc<AtomicU64>, _args, _ctx| async move {
                let prev = counter.fetch_add(1, Ordering::SeqCst);
                Ok(json!({ "count": prev + 1 }))
            },
        );

        let runner = ToolRunner::new();
        runner.register(tool);

        // Three independent invocations.
        let r1 = runner.execute("tick", json!({})).await.unwrap();
        let r2 = runner.execute("tick", json!({})).await.unwrap();
        let r3 = runner.execute("tick", json!({})).await.unwrap();

        // Each call saw the running total — the same state was threaded through.
        assert_eq!(r1["count"], json!(1));
        assert_eq!(r2["count"], json!(2));
        assert_eq!(r3["count"], json!(3));

        // The handle the test still holds reflects all three mutations: the
        // framework cloned a SHARED handle per call, not an independent copy.
        assert_eq!(counter.load(Ordering::SeqCst), 3);
    }
}