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