Skip to main content

agent_base/tool/
mod.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use async_trait::async_trait;
5use serde_json::{Value, json};
6use tokio::sync::mpsc;
7
8use crate::engine::SessionStore;
9use crate::llm::StreamClient;
10use crate::types::{AgentError, AgentResult, SessionId, UserEvent};
11
12pub mod auto_continue;
13pub mod policy;
14pub mod update_plan;
15
16pub use auto_continue::AutoContinueTool;
17pub use update_plan::UpdatePlanTool;
18
19pub use policy::{DenyAllToolPolicy, ToolPolicy};
20
21/// Structured content returned by a tool, aligned with the MCP `content`
22/// array shape (no envelope, no orchestration/failure/truncation semantics).
23///
24/// Only `Text` is consumed by the first LLM adapter; `Image` is shape-reserved
25/// and the adapter reports "not supported" rather than silently dropping it.
26#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
27#[serde(tag = "type", rename_all = "snake_case")]
28pub enum Content {
29    Text {
30        text: String,
31    },
32    /// Base64-encoded image payload.
33    Image {
34        data: String,
35        mime_type: String,
36    },
37}
38
39impl Content {
40    pub fn text(s: impl Into<String>) -> Self {
41        Content::Text { text: s.into() }
42    }
43
44    pub fn image(data: impl Into<String>, mime_type: impl Into<String>) -> Self {
45        Content::Image {
46            data: data.into(),
47            mime_type: mime_type.into(),
48        }
49    }
50}
51
52impl From<Content> for Vec<Content> {
53    fn from(c: Content) -> Self {
54        vec![c]
55    }
56}
57
58/// Join the textual portion of tool output into a single string for display
59/// and session history. Non-text variants (e.g. `Image`) are skipped.
60pub fn content_text(contents: &[Content]) -> String {
61    contents
62        .iter()
63        .filter_map(|c| match c {
64            Content::Text { text } => Some(text.as_str()),
65            Content::Image { .. } => None,
66        })
67        .collect::<Vec<_>>()
68        .join("\n")
69}
70
71#[derive(Clone)]
72pub struct ToolContext {
73    pub session_id: SessionId,
74    /// Channel for sending user-space events (progress, sub-agent, structured).
75    /// Tools should use `emit_user_event()` or `emit_progress()`.
76    pub user_event_tx: mpsc::UnboundedSender<UserEvent>,
77    pub llm_client: Option<Arc<dyn StreamClient>>,
78    pub session_store: Option<Arc<dyn SessionStore>>,
79    /// Language preference for tool output.
80    /// Defaults to `Language::En` if not set.
81    pub language: crate::types::Language,
82    /// Cancellation token for checking if the operation should be cancelled.
83    pub cancel_token: tokio_util::sync::CancellationToken,
84    /// Output budget for this call, in characters (set from the engine's
85    /// `max_tool_output_chars`). Tools that can return large results (e.g.
86    /// `read_file`) should self-truncate to this bound and mark the cut, so
87    /// the engine's hard reject (§6.5) never fires for a paginated read.
88    pub max_output_chars: Option<usize>,
89    /// Internal runtime event bus (framework tools emit `RuntimeEvent`s here).
90    /// `pub(crate)` — engine-internal; user tools should use `emit_user_event()`.
91    pub(crate) event_bus: crate::engine::EventBus,
92}
93
94impl ToolContext {
95    /// Send a user-space event (progress, sub-agent forwarding, structured data).
96    pub fn emit_user_event(&self, event: UserEvent) {
97        let _ = self.user_event_tx.send(event);
98    }
99
100    /// Convenience: send a progress event with text.
101    pub fn emit_progress(&self, text: impl Into<String>) {
102        self.emit_user_event(UserEvent::Progress { text: text.into() });
103    }
104
105    /// Convenience: emit a partial result during long-running tool execution.
106    /// `is_partial: true` means more output is coming; `false` means final.
107    pub fn emit_partial_result(
108        &self,
109        tool_call_id: &str,
110        content: impl Into<String>,
111        is_partial: bool,
112    ) {
113        self.emit_user_event(UserEvent::ToolPartialResult {
114            tool_call_id: tool_call_id.to_string(),
115            content: content.into(),
116            is_partial,
117        });
118    }
119
120    /// Test-only constructor: a `ToolContext` with a disconnected event
121    /// channel and no LLM/session backends. Lets downstream tests drop their
122    /// bespoke `dummy_ctx()` helpers.
123    pub fn for_test() -> Self {
124        let (tx, _rx) = mpsc::unbounded_channel();
125        ToolContext {
126            session_id: SessionId::new(0),
127            user_event_tx: tx,
128            llm_client: None,
129            session_store: None,
130            language: crate::types::Language::En,
131            cancel_token: tokio_util::sync::CancellationToken::new(),
132            max_output_chars: None,
133            event_bus: crate::engine::EventBus::new(1),
134        }
135    }
136}
137
138/// Machine-readable metadata for a registered tool — origin, version, and
139/// runtime requirements in a stable shape consumers can inspect without
140/// parsing the LLM-facing definition JSON.
141#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
142pub struct ToolMetadata {
143    /// Tool name (matches [`Tool::name`]).
144    pub name: String,
145    /// Human-readable description (matches the description in [`Tool::description`]).
146    pub description: String,
147    /// Where this tool comes from: a crate name (e.g. `"phi-tools"`), a
148    /// framework identifier (`"agent-base"`, `"agent-works"`), or
149    /// `"custom"` for user-defined tools.
150    pub origin: String,
151    /// Crate / package version, or `"unknown"` when built outside a crate.
152    pub version: String,
153    /// Optional runtime requirements or capabilities this tool depends on
154    /// (e.g. `["chrome-cdp"]` for browser tools). Empty when there are
155    /// none.
156    pub requirements: Vec<String>,
157}
158
159#[async_trait]
160pub trait Tool: Send + Sync {
161    fn name(&self) -> &'static str;
162    /// Human-readable description of what this tool does and when to use it.
163    fn description(&self) -> &'static str;
164    /// JSON Schema for the tool's input arguments (MCP `inputSchema` shape,
165    /// without the provider envelope).
166    fn schema(&self) -> Value;
167    async fn call(&self, args: &Value, ctx: &ToolContext) -> AgentResult<Vec<Content>>;
168
169    /// Timeout for this tool in milliseconds.
170    ///
171    /// Returns `Some(ms)` to enforce a timeout, or `None` to use the
172    /// framework's default timeout from `ToolConfig.default_tool_timeout_ms`.
173    /// Tools that need more or less time should override this method.
174    fn timeout_ms(&self) -> Option<u64> {
175        None  // Use framework default
176    }
177
178    /// Machine-readable metadata for tool introspection.
179    ///
180    /// The default implementation derives `name` and `description` from
181    /// [`Tool::name`] and [`Tool::description`], sets `origin` to `"custom"`,
182    /// and leaves `requirements` empty. Tool authors are encouraged to
183    /// override this to provide an accurate `origin` and `version`.
184    fn metadata(&self) -> ToolMetadata {
185        ToolMetadata {
186            name: self.name().to_string(),
187            description: self.description().to_string(),
188            origin: "custom".to_string(),
189            version: "unknown".to_string(),
190            requirements: vec![],
191        }
192    }
193}
194
195
196#[async_trait]
197pub trait TypedTool: Send + Sync {
198    type Args: serde::de::DeserializeOwned + schemars::JsonSchema;
199    type Output: serde::Serialize;
200
201    fn name(&self) -> &'static str;
202    fn description(&self) -> &'static str;
203    async fn call_typed(&self, args: Self::Args, ctx: &ToolContext) -> AgentResult<Self::Output>;
204
205    fn format_output(&self, output: Self::Output) -> Content {
206        // A `String` output is emitted verbatim; any other serializable type is
207        // rendered as JSON. This avoids `serde_json::to_string` wrapping a plain
208        // string in literal double quotes (`hello` → `"hello"`), which would leak
209        // quotes into the LLM-visible tool result.
210        match serde_json::to_value(&output) {
211            Ok(serde_json::Value::String(s)) => Content::text(s),
212            Ok(other) => Content::text(other.to_string()),
213            Err(_) => Content::text(String::new()),
214        }
215    }
216
217    /// Machine-readable origin of this tool (crate name, `"agent-base"`, or `"custom"`).
218    fn origin(&self) -> &'static str {
219        "custom"
220    }
221
222    /// Crate/package version, or `"unknown"` when built outside a crate.
223    fn version(&self) -> &'static str {
224        "unknown"
225    }
226}
227
228#[async_trait]
229impl<T: TypedTool + Send + Sync + 'static> Tool for T {
230    fn name(&self) -> &'static str {
231        TypedTool::name(self)
232    }
233
234    fn description(&self) -> &'static str {
235        TypedTool::description(self)
236    }
237
238    fn schema(&self) -> Value {
239        // Generate a provider-safe JSON Schema: Draft 7 (not 2020-12), with
240        // nested subschemas inlined (no `$ref`/`$defs`/`/definitions`) and no
241        // root `$schema`/meta-schema key. OpenAI-compatible function-calling
242        // rejects `$ref` and 2020-12's `$defs`, so the default 2020-12 output
243        // would break any `Args` containing a nested enum or struct.
244        let settings = schemars::generate::SchemaSettings::draft07().with(|s| {
245            s.inline_subschemas = true;
246            s.meta_schema = None;
247        });
248        let generator = schemars::SchemaGenerator::new(settings);
249        let schema = generator.into_root_schema_for::<T::Args>();
250        serde_json::to_value(schema).unwrap_or(Value::Null)
251    }
252
253    fn metadata(&self) -> ToolMetadata {
254        ToolMetadata {
255            name: self.name().to_string(),
256            description: self.description().to_string(),
257            origin: self.origin().to_string(),
258            version: self.version().to_string(),
259            requirements: vec![],
260        }
261    }
262
263    async fn call(&self, args: &Value, ctx: &ToolContext) -> AgentResult<Vec<Content>> {
264        let typed_args: T::Args =
265            serde_json::from_value(args.clone()).map_err(|_| AgentError::ToolArgsInvalid {
266                name: self.name().to_string(),
267                raw: args.to_string(),
268            })?;
269        let output = self.call_typed(typed_args, ctx).await?;
270        Ok(vec![self.format_output(output)])
271    }
272}
273
274/// Render a single tool's definition into the OpenAI function-calling
275/// envelope. Tools only provide `name`/`description`/`schema`; the envelope is
276/// assembled here at the LLM boundary (Anthropic gets its own renderer, and
277/// tool authors stay protocol-agnostic).
278pub fn render_tool_definition(tool: &dyn Tool) -> Value {
279    json!({
280        "type": "function",
281        "function": {
282            "name": tool.name(),
283            "description": tool.description(),
284            "parameters": tool.schema(),
285        }
286    })
287}
288
289pub(crate) type ToolRef = Arc<dyn Tool>;
290
291#[derive(Clone, Default)]
292pub struct ToolRegistry {
293    tools: HashMap<String, ToolRef>,
294}
295
296impl ToolRegistry {
297    pub fn register(&mut self, tool: impl Tool + 'static) {
298        self.tools.insert(tool.name().to_string(), Arc::new(tool));
299    }
300
301    pub fn register_arc(&mut self, tool: Arc<dyn Tool>) {
302        self.tools.insert(tool.name().to_string(), tool);
303    }
304
305    /// Remove a tool from the registry by name.
306    pub fn remove(&mut self, name: &str) {
307        self.tools.remove(name);
308    }
309
310    pub fn get(&self, name: &str) -> Option<ToolRef> {
311        self.tools.get(name).cloned()
312    }
313
314    pub fn definitions(&self) -> Vec<Value> {
315        let mut tools: Vec<_> = self.tools.values().collect();
316        tools.sort_by_key(|t| t.name());
317        tools
318            .into_iter()
319            .map(|t| render_tool_definition(t.as_ref()))
320            .collect()
321    }
322
323    pub fn len(&self) -> usize {
324        self.tools.len()
325    }
326
327    pub fn is_empty(&self) -> bool {
328        self.tools.is_empty()
329    }
330
331    /// Collect metadata for every registered tool, sorted by name.
332    ///
333    /// This is the preferred introspection API for consumers — it returns a
334    /// stable `ToolMetadata` struct per tool instead of having callers parse
335    /// the LLM-facing JSON definitions.
336    pub fn metadatas(&self) -> Vec<ToolMetadata> {
337        let mut list: Vec<_> = self.tools.values().map(|tool| tool.metadata()).collect();
338        list.sort_by(|a, b| a.name.cmp(&b.name));
339        list
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn content_text_ctor_and_into_vec() {
349        let c = Content::text("hello");
350        let v: Vec<Content> = c.clone().into();
351        assert_eq!(v.len(), 1);
352        assert!(matches!(v[0], Content::Text { .. }));
353        assert!(matches!(&c, Content::Text { text } if text == "hello"));
354    }
355
356    #[test]
357    fn content_serializes_with_type_tag() {
358        let c = Content::text("hi");
359        let j = serde_json::to_value(&c).unwrap();
360        assert_eq!(j["type"], "text");
361        assert_eq!(j["text"], "hi");
362    }
363
364    #[test]
365    fn tool_context_for_test_constructs() {
366        let ctx = ToolContext::for_test();
367        assert!(ctx.llm_client.is_none());
368        assert!(ctx.session_store.is_none());
369        assert!(!ctx.cancel_token.is_cancelled());
370        ctx.emit_progress("hello");
371    }
372
373    #[test]
374    fn typed_tool_schema_is_derived_from_args() {
375        #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)]
376        struct GreetArgs {
377            name: String,
378            #[serde(default)]
379            times: u32,
380        }
381
382        let schema = schemars::schema_for!(GreetArgs);
383        let j = serde_json::to_value(&schema).unwrap();
384        // The derived schema exposes the struct's fields as object properties.
385        assert!(j["properties"]["name"].is_object());
386        assert!(j["properties"]["times"].is_object());
387    }
388
389    #[test]
390    fn typed_tool_schema_is_provider_safe_for_nested_enum() {
391        #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)]
392        enum Status {
393            Active,
394            Paused,
395        }
396
397        #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)]
398        struct Args {
399            name: String,
400            status: Status,
401        }
402
403        #[derive(Default)]
404        struct NestedTool;
405        #[async_trait]
406        impl TypedTool for NestedTool {
407            type Args = Args;
408            type Output = String;
409            fn name(&self) -> &'static str {
410                "nested"
411            }
412            fn description(&self) -> &'static str {
413                ""
414            }
415            async fn call_typed(
416                &self,
417                _args: Args,
418                _ctx: &ToolContext,
419            ) -> crate::types::AgentResult<String> {
420                Ok(String::new())
421            }
422        }
423
424        let schema = Tool::schema(&NestedTool);
425        let raw = schema.to_string();
426        // OpenAI-compatible function-calling rejects $ref / $defs; the nested
427        // enum must be inlined rather than referenced.
428        assert!(!raw.contains("$ref"), "schema contains $ref: {raw}");
429        assert!(!raw.contains("$defs"), "schema contains $defs: {raw}");
430        assert!(
431            !raw.contains("definitions"),
432            "schema has definitions: {raw}"
433        );
434        assert!(schema.get("$schema").is_none(), "schema has $schema key");
435
436        // The enum variants are inlined directly under properties.status.
437        let variants: Vec<&str> = schema["properties"]["status"]["enum"]
438            .as_array()
439            .unwrap()
440            .iter()
441            .map(|v| v.as_str().unwrap())
442            .collect();
443        assert!(variants.contains(&"Active"), "missing Active: {variants:?}");
444        assert!(variants.contains(&"Paused"), "missing Paused: {variants:?}");
445    }
446
447    #[test]
448    fn definitions_are_sorted_by_name() {
449        struct NamedTool(&'static str);
450        #[async_trait::async_trait]
451        impl Tool for NamedTool {
452            fn name(&self) -> &'static str {
453                self.0
454            }
455            fn description(&self) -> &'static str {
456                ""
457            }
458            fn schema(&self) -> serde_json::Value {
459                serde_json::Value::Null
460            }
461            async fn call(
462                &self,
463                _args: &serde_json::Value,
464                _ctx: &ToolContext,
465            ) -> crate::types::AgentResult<Vec<Content>> {
466                Ok(vec![])
467            }
468        }
469
470        let mut registry = ToolRegistry::default();
471        registry.register(NamedTool("zeta"));
472        registry.register(NamedTool("alpha"));
473        registry.register(NamedTool("mike"));
474
475        let defs = registry.definitions();
476        let names: Vec<&str> = defs
477            .iter()
478            .map(|d| d["function"]["name"].as_str().unwrap())
479            .collect();
480        assert_eq!(names, vec!["alpha", "mike", "zeta"]);
481    }
482
483    // ── B4: content image / partial result / typed-tool blanket / registry ──
484
485    #[test]
486    fn content_image_and_content_text_skips_images() {
487        let img = Content::image("base64data", "image/png");
488        assert!(
489            matches!(&img, Content::Image { data, mime_type } if data == "base64data" && mime_type == "image/png")
490        );
491
492        let text = content_text(&[
493            Content::text("a"),
494            Content::image("b", "image/png"),
495            Content::text("c"),
496        ]);
497        assert_eq!(text, "a\nc");
498    }
499
500    #[test]
501    fn emit_partial_result_sends_event() {
502        let (tx, mut rx) = mpsc::unbounded_channel();
503        let ctx = ToolContext {
504            session_id: SessionId::new(0),
505            user_event_tx: tx,
506            llm_client: None,
507            session_store: None,
508            language: crate::types::Language::En,
509            cancel_token: tokio_util::sync::CancellationToken::new(),
510            max_output_chars: None,
511            event_bus: crate::engine::EventBus::new(1),
512        };
513        ctx.emit_partial_result("tc1", "partial", true);
514        match rx.try_recv().unwrap() {
515            UserEvent::ToolPartialResult {
516                tool_call_id,
517                content,
518                is_partial,
519            } => {
520                assert_eq!(tool_call_id, "tc1");
521                assert_eq!(content, "partial");
522                assert!(is_partial);
523            }
524            other => panic!("unexpected event: {other:?}"),
525        }
526    }
527
528    // A concrete TypedTool exercising the blanket `impl Tool for T`.
529    #[derive(schemars::JsonSchema, serde::Deserialize, serde::Serialize)]
530    struct GreetArgs {
531        name: String,
532    }
533
534    struct GreetTool;
535    #[async_trait]
536    impl TypedTool for GreetTool {
537        type Args = GreetArgs;
538        type Output = String;
539        fn name(&self) -> &'static str {
540            "greet"
541        }
542        fn description(&self) -> &'static str {
543            "greets a name"
544        }
545        fn origin(&self) -> &'static str {
546            "test-crate"
547        }
548        fn version(&self) -> &'static str {
549            "1.0.0"
550        }
551        async fn call_typed(&self, args: GreetArgs, _ctx: &ToolContext) -> AgentResult<String> {
552            Ok(format!("Hello, {}!", args.name))
553        }
554    }
555
556    #[test]
557    fn typed_tool_blanket_delegates_name_description() {
558        let t = GreetTool;
559        assert_eq!(Tool::name(&t), "greet");
560        assert_eq!(Tool::description(&t), "greets a name");
561    }
562
563    #[test]
564    fn typed_tool_metadata_uses_origin_and_version() {
565        let m = Tool::metadata(&GreetTool);
566        assert_eq!(m.name, "greet");
567        assert_eq!(m.description, "greets a name");
568        assert_eq!(m.origin, "test-crate");
569        assert_eq!(m.version, "1.0.0");
570        assert!(m.requirements.is_empty());
571    }
572
573    #[tokio::test]
574    async fn typed_tool_call_deserializes_and_formats() {
575        let ctx = ToolContext::for_test();
576        let out = Tool::call(&GreetTool, &json!({"name": "world"}), &ctx)
577            .await
578            .unwrap();
579        // format_output emits a String verbatim (not JSON-quoted).
580        assert_eq!(content_text(&out), "Hello, world!");
581    }
582
583    // A struct-typed output confirms non-String outputs still serialize as JSON.
584    #[derive(serde::Serialize)]
585    struct GreetResult {
586        message: String,
587    }
588
589    struct GreetStructTool;
590    #[async_trait]
591    impl TypedTool for GreetStructTool {
592        type Args = GreetArgs;
593        type Output = GreetResult;
594        fn name(&self) -> &'static str {
595            "greet_struct"
596        }
597        fn description(&self) -> &'static str {
598            "greets as json"
599        }
600        async fn call_typed(
601            &self,
602            args: GreetArgs,
603            _ctx: &ToolContext,
604        ) -> AgentResult<GreetResult> {
605            Ok(GreetResult {
606                message: format!("Hello, {}!", args.name),
607            })
608        }
609    }
610
611    #[test]
612    fn format_output_json_serializes_struct() {
613        let out = GreetStructTool.format_output(GreetResult {
614            message: "hi".into(),
615        });
616        assert_eq!(content_text(&[out]), r#"{"message":"hi"}"#);
617    }
618
619    #[tokio::test]
620    async fn typed_tool_call_invalid_args_is_tool_args_invalid() {
621        let ctx = ToolContext::for_test();
622        let err = Tool::call(&GreetTool, &json!({"nope": 1}), &ctx)
623            .await
624            .unwrap_err();
625        assert!(matches!(err, AgentError::ToolArgsInvalid { .. }));
626    }
627
628    struct NamedTool(&'static str);
629    #[async_trait]
630    impl Tool for NamedTool {
631        fn name(&self) -> &'static str {
632            self.0
633        }
634        fn description(&self) -> &'static str {
635            ""
636        }
637        fn schema(&self) -> serde_json::Value {
638            serde_json::Value::Null
639        }
640        async fn call(
641            &self,
642            _args: &serde_json::Value,
643            _ctx: &ToolContext,
644        ) -> AgentResult<Vec<Content>> {
645            Ok(vec![])
646        }
647    }
648
649    #[test]
650    fn registry_register_arc_get_remove_len_is_empty() {
651        let mut r = ToolRegistry::default();
652        assert!(r.is_empty());
653        assert_eq!(r.len(), 0);
654
655        let t: Arc<dyn Tool> = Arc::new(NamedTool("x"));
656        r.register_arc(t);
657        assert!(!r.is_empty());
658        assert_eq!(r.len(), 1);
659        assert!(r.get("x").is_some());
660        assert!(r.get("missing").is_none());
661
662        r.remove("x");
663        assert!(r.is_empty());
664    }
665
666    #[test]
667    fn metadatas_are_sorted_by_name() {
668        let mut r = ToolRegistry::default();
669        r.register(NamedTool("zeta"));
670        r.register(NamedTool("alpha"));
671
672        let metas = r.metadatas();
673        let names: Vec<&str> = metas.iter().map(|m| m.name.as_str()).collect();
674        assert_eq!(names, vec!["alpha", "zeta"]);
675        assert_eq!(metas[0].origin, "custom");
676        assert_eq!(metas[0].version, "unknown");
677    }
678}