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}