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}