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