Skip to main content

entelix_core/tools/
scope.rs

1//! `ToolDispatchScope` + `ScopedToolLayer` — operator-supplied hook
2//! that wraps every tool dispatch future to inject ambient
3//! request-scope state (tokio task-locals, tracing scopes, RLS
4//! `SET LOCAL` settings, etc.).
5//!
6//! ## Why a hook
7//!
8//! Some tool implementations read state that the SDK cannot supply
9//! through `ExecutionContext` directly because the state lives in a
10//! thread-local or task-local rather than in a typed field. The
11//! canonical example is Postgres row-level security: the tool's
12//! query path reads `current_setting('entelix.tenant_id', true)`,
13//! which is `SET LOCAL`-scoped to the current transaction. The SDK
14//! must enter that scope before the tool's future starts polling
15//! and must restore the prior scope when the future resolves.
16//!
17//! Operators implement [`ToolDispatchScope::wrap`] with the
18//! enter/exit machinery their backend requires (e.g.
19//! `tokio::task_local!::scope(value, fut)`), wrap the trait impl in
20//! a [`ScopedToolLayer`], and attach it to a `ToolRegistry` via
21//! [`crate::tools::ToolRegistry::layer`]. Sub-agents that narrow
22//! the parent registry through `restricted_to` / `filter` inherit
23//! the layer stack by `Arc` — the wrap fires for every
24//! sub-agent dispatch automatically.
25//!
26//! ## Composition with other layers
27//!
28//! `ScopedToolLayer` is one more Tower middleware in the registry's
29//! layer stack. The reasonable layering for the canonical entelix
30//! middlewares — observability outermost, scope innermost —
31//! corresponds to *registering* in inside-out order, since
32//! [`crate::tools::ToolRegistry::layer`] makes the
33//! **last-registered layer outermost**:
34//!
35//! ```ignore
36//! ToolRegistry::new()
37//!     .layer(ScopedToolLayer::new(my_scope))   // innermost (registered first)
38//!     .layer(ApprovalLayer::new(approver))     //
39//!     .layer(PolicyLayer::new(...))            //
40//!     .layer(OtelLayer::new(...))              // outermost (registered last)
41//!     .register(my_tool)?
42//! ```
43//!
44//! Dispatch flow on each tool call: `OtelLayer → PolicyLayer →
45//! ApprovalLayer → ScopedToolLayer → Tool::execute`. The scope is
46//! active during the leaf `Tool::execute` future and through any
47//! `?`-returning code on the way back; PII/cost middleware run
48//! before the scope is entered. Operators that need the scope
49//! active during PII redaction reverse the registration order
50//! (move `.layer(ScopedToolLayer::new(...))` later in the chain).
51
52use std::sync::Arc;
53use std::task::{Context, Poll};
54
55use futures::future::BoxFuture;
56use serde_json::Value;
57use tower::{Layer, Service};
58
59use crate::context::ExecutionContext;
60use crate::error::{Error, Result};
61use crate::service::ToolInvocation;
62
63/// Operator-supplied wrapper for tool-dispatch futures.
64///
65/// Implementations enter ambient scope state (task-locals,
66/// tracing scopes, RLS settings) before the wrapped future starts
67/// polling and restore the prior state when it resolves. The
68/// trait is object-safe so concrete impls plug into
69/// [`ScopedToolLayer`] behind `Arc<dyn ToolDispatchScope>`.
70///
71/// The wrap method takes `ctx` by value (the field is cheaply
72/// `Clone` — `Arc<str>` for `tenant_id`, refcounted handles for
73/// extensions). Implementations that need only one field
74/// (typically `tenant_id`) read it once and discard the ctx; the
75/// owned shape avoids lifetime gymnastics in the trait signature
76/// and keeps the trait object-safe.
77pub trait ToolDispatchScope: Send + Sync + 'static {
78    /// Wrap `fut` to observe the scope's ambient state.
79    /// `ctx` carries the current request-scope state (tenant id,
80    /// thread id, extensions) the wrapper may consult to seed its
81    /// task-locals.
82    fn wrap(
83        &self,
84        ctx: ExecutionContext,
85        fut: BoxFuture<'static, Result<Value>>,
86    ) -> BoxFuture<'static, Result<Value>>;
87}
88
89/// `tower::Layer<S>` that wraps a `Service<ToolInvocation, Response =
90/// Value, Error = Error>` so its `call` future is wrapped by the
91/// operator-supplied [`ToolDispatchScope`].
92///
93/// Attach via [`crate::tools::ToolRegistry::layer`]. Cloning is
94/// cheap (the wrapper is held behind `Arc`).
95pub struct ScopedToolLayer {
96    wrapper: Arc<dyn ToolDispatchScope>,
97}
98
99impl ScopedToolLayer {
100    /// Patch-version-stable identifier surfaced through
101    /// `ToolRegistry::layer_names`. Renaming this constant is a
102    /// breaking change for dashboards keyed off the value.
103    pub const NAME: &'static str = "tool_scope";
104
105    /// Wrap a concrete [`ToolDispatchScope`] for layer attachment.
106    /// The boxed-trait shape lets operators stack heterogeneous
107    /// scope wrappers if they need to compose (e.g. a tenant-RLS
108    /// scope outside a tracing-baggage scope).
109    pub fn new<W>(wrapper: W) -> Self
110    where
111        W: ToolDispatchScope,
112    {
113        Self {
114            wrapper: Arc::new(wrapper),
115        }
116    }
117
118    /// Wrap an already-Arc'd [`ToolDispatchScope`]. Convenient when
119    /// the same scope handle is shared across multiple registries.
120    #[must_use]
121    pub fn from_arc(wrapper: Arc<dyn ToolDispatchScope>) -> Self {
122        Self { wrapper }
123    }
124}
125
126impl Clone for ScopedToolLayer {
127    fn clone(&self) -> Self {
128        Self {
129            wrapper: Arc::clone(&self.wrapper),
130        }
131    }
132}
133
134impl<S> Layer<S> for ScopedToolLayer {
135    type Service = ScopedToolService<S>;
136
137    fn layer(&self, inner: S) -> Self::Service {
138        ScopedToolService {
139            inner,
140            wrapper: Arc::clone(&self.wrapper),
141        }
142    }
143}
144
145impl crate::NamedLayer for ScopedToolLayer {
146    fn layer_name(&self) -> &'static str {
147        Self::NAME
148    }
149}
150
151/// `tower::Service<ToolInvocation>` wrapper produced by
152/// [`ScopedToolLayer::layer`]. Dispatches `inner.call(invocation)`
153/// and runs the resulting future under the configured
154/// [`ToolDispatchScope::wrap`].
155pub struct ScopedToolService<S> {
156    inner: S,
157    wrapper: Arc<dyn ToolDispatchScope>,
158}
159
160impl<S: Clone> Clone for ScopedToolService<S> {
161    fn clone(&self) -> Self {
162        Self {
163            inner: self.inner.clone(),
164            wrapper: Arc::clone(&self.wrapper),
165        }
166    }
167}
168
169impl<S> Service<ToolInvocation> for ScopedToolService<S>
170where
171    S: Service<ToolInvocation, Response = Value, Error = Error> + Send + 'static,
172    S::Future: Send + 'static,
173{
174    type Response = Value;
175    type Error = Error;
176    type Future = BoxFuture<'static, Result<Value>>;
177
178    #[inline]
179    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<()>> {
180        self.inner.poll_ready(cx)
181    }
182
183    fn call(&mut self, invocation: ToolInvocation) -> Self::Future {
184        let ctx = invocation.ctx.clone();
185        let inner_fut = self.inner.call(invocation);
186        self.wrapper.wrap(ctx, Box::pin(inner_fut))
187    }
188}
189
190#[cfg(test)]
191#[allow(clippy::unwrap_used)]
192mod tests {
193    use std::sync::atomic::{AtomicUsize, Ordering};
194
195    use serde_json::json;
196
197    use super::*;
198    use crate::tools::{Tool, ToolMetadata, ToolRegistry};
199    use async_trait::async_trait;
200
201    struct CountingScope {
202        wraps: Arc<AtomicUsize>,
203    }
204
205    impl ToolDispatchScope for CountingScope {
206        fn wrap(
207            &self,
208            _ctx: ExecutionContext,
209            fut: BoxFuture<'static, Result<Value>>,
210        ) -> BoxFuture<'static, Result<Value>> {
211            self.wraps.fetch_add(1, Ordering::SeqCst);
212            fut
213        }
214    }
215
216    struct EchoTool {
217        metadata: ToolMetadata,
218    }
219
220    impl EchoTool {
221        fn new() -> Self {
222            Self {
223                metadata: ToolMetadata::function(
224                    "echo",
225                    "Echo input verbatim.",
226                    json!({ "type": "object" }),
227                ),
228            }
229        }
230    }
231
232    #[async_trait]
233    impl Tool for EchoTool {
234        fn metadata(&self) -> &ToolMetadata {
235            &self.metadata
236        }
237
238        async fn execute(&self, input: Value, _ctx: &crate::AgentContext<()>) -> Result<Value> {
239            Ok(input)
240        }
241    }
242
243    #[tokio::test]
244    async fn scope_wrap_fires_on_dispatch() {
245        let wraps = Arc::new(AtomicUsize::new(0));
246        let scope = CountingScope {
247            wraps: Arc::clone(&wraps),
248        };
249        let registry = ToolRegistry::new()
250            .layer(ScopedToolLayer::new(scope))
251            .register(Arc::new(EchoTool::new()))
252            .unwrap();
253        let ctx = ExecutionContext::new();
254        let result = registry
255            .dispatch("", "echo", json!({"x": 1}), &ctx)
256            .await
257            .unwrap();
258        assert_eq!(result, json!({"x": 1}));
259        assert_eq!(wraps.load(Ordering::SeqCst), 1);
260    }
261
262    #[tokio::test]
263    async fn scope_wrap_fires_per_dispatch() {
264        let wraps = Arc::new(AtomicUsize::new(0));
265        let scope = CountingScope {
266            wraps: Arc::clone(&wraps),
267        };
268        let registry = ToolRegistry::new()
269            .layer(ScopedToolLayer::new(scope))
270            .register(Arc::new(EchoTool::new()))
271            .unwrap();
272        let ctx = ExecutionContext::new();
273        for _ in 0..3 {
274            registry
275                .dispatch("", "echo", json!({"x": 1}), &ctx)
276                .await
277                .unwrap();
278        }
279        assert_eq!(wraps.load(Ordering::SeqCst), 3);
280    }
281
282    #[tokio::test]
283    async fn scope_wrap_inherited_by_narrowed_view() {
284        // Sub-agent narrowing pattern shares the layer
285        // factory by Arc — a scope attached to the parent must
286        // fire on every narrowed-view dispatch as well.
287        let wraps = Arc::new(AtomicUsize::new(0));
288        let scope = CountingScope {
289            wraps: Arc::clone(&wraps),
290        };
291        let parent = ToolRegistry::new()
292            .layer(ScopedToolLayer::new(scope))
293            .register(Arc::new(EchoTool::new()))
294            .unwrap();
295        let narrowed = parent.restricted_to(&["echo"]).unwrap();
296        let ctx = ExecutionContext::new();
297        narrowed
298            .dispatch("", "echo", json!({"x": 1}), &ctx)
299            .await
300            .unwrap();
301        assert_eq!(wraps.load(Ordering::SeqCst), 1);
302    }
303}