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