Skip to main content

klieo_core/
tool.rs

1//! Tool trait, invoker, and tool context.
2//!
3//! Tools are I/O leaves. They receive a narrowed [`ToolCtx`] holding only
4//! bus + KV access — they cannot reach into LLM or memory directly.
5//! Memory updates triggered by tools must flow through bus events that
6//! agents subscribe to, keeping the dependency graph one-directional.
7
8use crate::bus::{JobQueue, KvStore, Pubsub};
9use crate::error::ToolError;
10use async_trait::async_trait;
11use std::sync::Arc;
12
13/// Verified caller principal carried on [`ToolCtx`] for server-side
14/// authorisation (resume-ticket binding) and non-PII attribution.
15///
16/// A newtype rather than a bare `String` so the value cannot be passed
17/// where arbitrary text is expected, and — deliberately NOT `Serialize`
18/// — it cannot be folded into an [`crate::Episode`] or any other
19/// persisted/LLM-visible structure by accident. The raw value reaches
20/// only server-side authz/tracing; attribution into the audit trail goes
21/// through [`crate::principal_hash`].
22#[derive(Clone, Debug)]
23pub struct CallerPrincipal(String);
24
25impl CallerPrincipal {
26    /// Wrap a verified principal supplied by the transport layer.
27    pub fn new(value: impl Into<String>) -> Self {
28        Self(value.into())
29    }
30
31    /// Borrow the raw principal for an authz compare or hashing. The
32    /// caller is responsible for keeping it off LLM-visible surfaces.
33    #[must_use]
34    pub fn as_str(&self) -> &str {
35        &self.0
36    }
37}
38
39/// Opaque cross-hop provenance anchor carried on [`ToolCtx`].
40///
41/// Same rationale as [`CallerPrincipal`]: a distinct, non-`Serialize`
42/// type so a caller-supplied anchor cannot leak into a persisted
43/// structure except through the explicit run-origin recording path,
44/// which copies its [`Self::as_str`] verbatim into a fresh `Episode`.
45#[derive(Clone, Debug)]
46pub struct ParentAnchor(String);
47
48impl ParentAnchor {
49    /// Wrap a validated cross-hop anchor supplied by the transport layer.
50    pub fn new(value: impl Into<String>) -> Self {
51        Self(value.into())
52    }
53
54    /// Borrow the raw anchor for recording as the run origin.
55    #[must_use]
56    pub fn as_str(&self) -> &str {
57        &self.0
58    }
59}
60
61/// Narrowed slice of `AgentContext` (see `agent.rs`) passed to tools.
62#[derive(Clone)]
63#[non_exhaustive]
64pub struct ToolCtx {
65    /// Pub/sub bus.
66    pub pubsub: Arc<dyn Pubsub>,
67    /// KV store.
68    pub kv: Arc<dyn KvStore>,
69    /// Job queue.
70    pub jobs: Arc<dyn JobQueue>,
71    /// Optional progress sender. Tools that wrap an [`crate::Agent`]
72    /// (e.g. `AgentAsToolInvoker` in klieo-mcp-server) overlay this
73    /// onto the minted `AgentContext`. Non-agent invokers ignore the
74    /// field.
75    pub progress: Option<tokio::sync::broadcast::Sender<crate::AgentEvent>>,
76    /// Cooperative cancellation token. HTTP/SSE transports pass a
77    /// request-scoped child token derived from the server's parent
78    /// cancel; tools should `select!` against `cancel.cancelled()` to
79    /// honor client disconnects mid-call.
80    pub cancel: tokio_util::sync::CancellationToken,
81    /// Optional outbound channel for server-initiated JSON-RPC
82    /// (e.g. MCP `sampling/createMessage`). Tools requiring the
83    /// outbound surface return `Err(Unsupported)` when `None`.
84    /// Default: `None`.
85    pub server_outbound: Option<Arc<dyn crate::ServerOutbound>>,
86    /// Verified caller principal for server-side authorisation
87    /// (e.g. binding a `klieo/run/resume` ticket to its issuer).
88    /// Server-side metadata ONLY: implementations MUST NOT append
89    /// this value to short-term/episodic memory, conversation history,
90    /// or any LLM-visible message — those surfaces are operator-
91    /// reviewed and PII-redacted on their own contract. `None` when
92    /// no authenticator is wired, when the transport carries no
93    /// principal (stdio, unauthenticated HTTP), or when the request
94    /// is anonymous.
95    pub caller_principal: Option<CallerPrincipal>,
96    /// Opaque cross-hop provenance anchor supplied by an authenticated
97    /// caller — by convention the caller's own provenance chain-entry id
98    /// (or its run's episodic-root hash). When set, the run records it as
99    /// its origin so klieo→klieo composition can be stitched across
100    /// deployment boundaries in the audit trail. Audit metadata ONLY:
101    /// implementations MUST NOT append it to short-term/episodic memory,
102    /// conversation history, or any LLM-visible message. The value is
103    /// recorded verbatim (stitching requires it to equal the caller's
104    /// chain-entry id), is a caller-ASSERTED, UNVERIFIED claim, and must
105    /// be treated as such downstream. `None` when no anchor was supplied
106    /// or the caller is unauthenticated.
107    pub parent_anchor: Option<ParentAnchor>,
108}
109
110impl ToolCtx {
111    /// Construct a `ToolCtx` with default `progress = None`, no
112    /// outbound channel, and a fresh uncancelled `CancellationToken`.
113    /// Builders below override.
114    pub fn new(pubsub: Arc<dyn Pubsub>, kv: Arc<dyn KvStore>, jobs: Arc<dyn JobQueue>) -> Self {
115        Self {
116            pubsub,
117            kv,
118            jobs,
119            progress: None,
120            cancel: tokio_util::sync::CancellationToken::new(),
121            server_outbound: None,
122            caller_principal: None,
123            parent_anchor: None,
124        }
125    }
126
127    /// Override the cancellation token (HTTP/SSE callers pass a
128    /// request-scoped child token).
129    #[must_use]
130    pub fn with_cancel(mut self, cancel: tokio_util::sync::CancellationToken) -> Self {
131        self.cancel = cancel;
132        self
133    }
134
135    /// Override the progress sender (HTTP/SSE callers pass the
136    /// per-request broadcast for `AgentEvent` forwarding).
137    #[must_use]
138    pub fn with_progress(
139        mut self,
140        progress: tokio::sync::broadcast::Sender<crate::AgentEvent>,
141    ) -> Self {
142        self.progress = Some(progress);
143        self
144    }
145
146    /// Attach a server-initiated outbound channel so tools can issue
147    /// reverse-direction JSON-RPC requests to the connected peer.
148    #[must_use]
149    pub fn with_server_outbound(mut self, outbound: Arc<dyn crate::ServerOutbound>) -> Self {
150        self.server_outbound = Some(outbound);
151        self
152    }
153
154    /// Stamp the verified caller principal so server-side
155    /// authorisation helpers (e.g. resume-ticket issuance) can bind
156    /// follow-up requests to the original caller. See the field
157    /// docstring for the prohibition on leaking this value into
158    /// LLM-visible memory.
159    #[must_use]
160    pub fn with_caller_principal(mut self, principal: String) -> Self {
161        self.caller_principal = Some(CallerPrincipal::new(principal));
162        self
163    }
164
165    /// Stamp the cross-hop provenance anchor (the caller's chain-entry
166    /// id) so the run can record its origin. See the field docstring for
167    /// the verbatim-record / unverified-claim / no-LLM-leak contract.
168    #[must_use]
169    pub fn with_parent_anchor(mut self, anchor: String) -> Self {
170        self.parent_anchor = Some(ParentAnchor::new(anchor));
171        self
172    }
173}
174
175/// One executable tool.
176///
177/// Implementations live wherever they make sense. The
178/// `klieo_macros::tool` proc-macro generates one for you from a
179/// plain async function.
180///
181/// ```
182/// # tokio_test::block_on(async {
183/// use async_trait::async_trait;
184/// use klieo_core::test_utils::noop_bus;
185/// use klieo_core::error::ToolError;
186/// use klieo_core::tool::{Tool, ToolCtx};
187/// use std::sync::OnceLock;
188///
189/// struct Echo;
190///
191/// #[async_trait]
192/// impl Tool for Echo {
193///     fn name(&self) -> &str { "echo" }
194///     fn description(&self) -> &str { "echoes input" }
195///     fn json_schema(&self) -> &serde_json::Value {
196///         static S: OnceLock<serde_json::Value> = OnceLock::new();
197///         S.get_or_init(|| serde_json::json!({"type": "object"}))
198///     }
199///     async fn invoke(&self, args: serde_json::Value, _ctx: ToolCtx)
200///         -> Result<serde_json::Value, ToolError>
201///     {
202///         Ok(args)
203///     }
204/// }
205///
206/// let (pubsub, _, kv, jobs) = noop_bus();
207/// let ctx = ToolCtx::new(pubsub, kv, jobs);
208/// let out = Echo.invoke(serde_json::json!({"y": 2}), ctx).await.unwrap();
209/// assert_eq!(out, serde_json::json!({"y": 2}));
210/// # });
211/// ```
212#[async_trait]
213pub trait Tool: Send + Sync {
214    /// Tool name shown to the LLM. Must be unique within a catalogue.
215    fn name(&self) -> &str;
216
217    /// Human-readable description shown to the LLM.
218    fn description(&self) -> &str;
219
220    /// JSON-schema for the tool's arguments.
221    fn json_schema(&self) -> &serde_json::Value;
222
223    /// Invoke the tool. Args are pre-validated against `json_schema`.
224    async fn invoke(
225        &self,
226        args: serde_json::Value,
227        ctx: ToolCtx,
228    ) -> Result<serde_json::Value, ToolError>;
229
230    /// Advisory metadata: whether this tool causes an irreversible side effect
231    /// (payment, mutation of external state, sent message, etc.). Defaults to
232    /// `false` (read-only); side-effecting tools should override to `true`.
233    ///
234    /// This flag does NOT, by itself, cause gating. Gating is opt-in: wrap a
235    /// tool in `klieo_ops::GatedTool` to enforce a pre-effect approval boundary
236    /// (it gates regardless of this flag). The flag is surfaced for callers and
237    /// audit metadata, not consumed by the runtime to auto-gate.
238    fn is_effectful(&self) -> bool {
239        false
240    }
241
242    /// Whether dispatch must record a redacted projection of this tool's
243    /// input, output, and error to [`crate::EpisodicMemory`] in place of
244    /// the raw values. Tools handling claimant PII MUST override to
245    /// return `true`; dispatch then never persists raw args/results for
246    /// the call, falling back to an opaque digest when no
247    /// [`crate::AuditRedactor`] is wired. Defaults to `false` (raw).
248    /// Hash-chain provenance is computed over the raw values upstream
249    /// and is unaffected by this flag.
250    fn redacts_audit(&self) -> bool {
251        false
252    }
253}
254
255/// Dispatches tool calls by name.
256///
257/// ```
258/// # tokio_test::block_on(async {
259/// use klieo_core::test_utils::{noop_bus, FakeToolInvoker};
260/// use klieo_core::{ToolCtx, ToolInvoker};
261/// let (pubsub, _, kv, jobs) = noop_bus();
262/// let inv = FakeToolInvoker::new()
263///     .with_tool("echo", "echoes back", |args| Ok(args));
264/// let ctx = ToolCtx::new(pubsub, kv, jobs);
265/// let out = inv.invoke("echo", serde_json::json!({"x": 1}), ctx).await.unwrap();
266/// assert_eq!(out, serde_json::json!({"x": 1}));
267/// assert_eq!(inv.catalogue().len(), 1);
268/// # });
269/// ```
270#[async_trait]
271pub trait ToolInvoker: Send + Sync {
272    /// Invoke `name` with `args` against the supplied context.
273    async fn invoke(
274        &self,
275        name: &str,
276        args: serde_json::Value,
277        ctx: ToolCtx,
278    ) -> Result<serde_json::Value, ToolError>;
279
280    /// Return the catalogue this invoker exposes.
281    fn catalogue(&self) -> Vec<crate::llm::ToolDef>;
282
283    /// Returns true if `invoke(name, args, ctx)` is safe to
284    /// re-execute from the original arguments — i.e. read-only,
285    /// deterministic, or duplicate execution has no business
286    /// effect.
287    ///
288    /// Per-tool granularity so an invoker can expose a mix of
289    /// idempotent + state-mutating tools. Default false (safe).
290    /// Tool authors override per-name only when the tool is
291    /// provably safe to re-execute.
292    ///
293    /// Cluster 0.24: when true for a given tool, the follower
294    /// replica detecting an orphaned stream re-invokes
295    /// `tools/call` from the cached args instead of writing a
296    /// terminal "leader died" frame. See ADR-024.
297    fn is_tool_idempotent(&self, _name: &str) -> bool {
298        false
299    }
300
301    /// Mirrors [`Tool::redacts_audit`] across the invoker boundary
302    /// (dispatch holds a [`ToolInvoker`], not the concrete [`Tool`]).
303    /// Per-name granularity so one invoker can mix PII-handling and
304    /// non-PII tools. Default false (raw).
305    fn tool_redacts_audit(&self, _name: &str) -> bool {
306        false
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[allow(dead_code)]
315    fn _assert_dyn_invoker(_: &dyn ToolInvoker) {}
316
317    #[allow(dead_code)]
318    fn _assert_dyn_tool(_: &dyn Tool) {}
319
320    struct StubInvoker;
321    #[async_trait::async_trait]
322    impl ToolInvoker for StubInvoker {
323        fn catalogue(&self) -> Vec<crate::llm::ToolDef> {
324            vec![]
325        }
326        async fn invoke(
327            &self,
328            _name: &str,
329            _args: serde_json::Value,
330            _ctx: ToolCtx,
331        ) -> Result<serde_json::Value, ToolError> {
332            Ok(serde_json::Value::Null)
333        }
334    }
335
336    #[test]
337    fn default_is_tool_idempotent_returns_false() {
338        let inv = StubInvoker;
339        assert!(!inv.is_tool_idempotent("anything"));
340        assert!(!inv.is_tool_idempotent(""));
341    }
342}
343
344#[cfg(test)]
345mod ctx_tests {
346    use super::*;
347    use crate::server_outbound::{ServerOutbound, ServerOutboundError};
348    use crate::test_utils::noop_bus;
349    use std::time::Duration;
350    use tokio_util::sync::CancellationToken;
351
352    #[test]
353    fn new_defaults_progress_none_and_cancel_uncancelled() {
354        let (pubsub, _, kv, jobs) = noop_bus();
355        let ctx = ToolCtx::new(pubsub, kv, jobs);
356        assert!(ctx.progress.is_none());
357        assert!(!ctx.cancel.is_cancelled());
358    }
359
360    #[test]
361    fn with_cancel_overrides_default_token() {
362        let (pubsub, _, kv, jobs) = noop_bus();
363        let token = CancellationToken::new();
364        let ctx = ToolCtx::new(pubsub, kv, jobs).with_cancel(token.clone());
365        token.cancel();
366        assert!(ctx.cancel.is_cancelled());
367    }
368
369    #[test]
370    fn with_progress_sets_sender() {
371        let (pubsub, _, kv, jobs) = noop_bus();
372        let (tx, _rx) = tokio::sync::broadcast::channel::<crate::AgentEvent>(8);
373        let ctx = ToolCtx::new(pubsub, kv, jobs).with_progress(tx);
374        assert!(ctx.progress.is_some());
375    }
376
377    struct StubOutbound;
378
379    #[async_trait::async_trait]
380    impl ServerOutbound for StubOutbound {
381        async fn outbound_request(
382            &self,
383            _method: &str,
384            _params: serde_json::Value,
385            _timeout: Duration,
386        ) -> Result<serde_json::Value, ServerOutboundError> {
387            Err(ServerOutboundError::Unsupported)
388        }
389    }
390
391    #[test]
392    fn new_defaults_server_outbound_none() {
393        let (pubsub, _, kv, jobs) = noop_bus();
394        let ctx = ToolCtx::new(pubsub, kv, jobs);
395        assert!(ctx.server_outbound.is_none());
396    }
397
398    #[test]
399    fn with_server_outbound_attaches_channel() {
400        let (pubsub, _, kv, jobs) = noop_bus();
401        let outbound: Arc<dyn ServerOutbound> = Arc::new(StubOutbound);
402        let ctx = ToolCtx::new(pubsub, kv, jobs).with_server_outbound(outbound);
403        assert!(ctx.server_outbound.is_some());
404    }
405
406    #[test]
407    fn new_defaults_caller_principal_none() {
408        let (pubsub, _, kv, jobs) = noop_bus();
409        let ctx = ToolCtx::new(pubsub, kv, jobs);
410        assert!(ctx.caller_principal.is_none());
411    }
412
413    #[test]
414    fn with_caller_principal_records_value() {
415        let (pubsub, _, kv, jobs) = noop_bus();
416        let ctx = ToolCtx::new(pubsub, kv, jobs).with_caller_principal("alice@x".into());
417        assert_eq!(
418            ctx.caller_principal.as_ref().map(CallerPrincipal::as_str),
419            Some("alice@x")
420        );
421    }
422
423    #[test]
424    fn new_defaults_parent_anchor_none() {
425        let (pubsub, _, kv, jobs) = noop_bus();
426        let ctx = ToolCtx::new(pubsub, kv, jobs);
427        assert!(ctx.parent_anchor.is_none());
428    }
429
430    #[test]
431    fn with_parent_anchor_records_value() {
432        let (pubsub, _, kv, jobs) = noop_bus();
433        let ctx = ToolCtx::new(pubsub, kv, jobs).with_parent_anchor("sha256:abc123".into());
434        assert_eq!(
435            ctx.parent_anchor.as_ref().map(ParentAnchor::as_str),
436            Some("sha256:abc123")
437        );
438    }
439}