diff --git a/docs/src/examples.md b/docs/src/examples.md index 01589d6..8c0c082 100644 --- a/docs/src/examples.md +++ b/docs/src/examples.md @@ -4,16 +4,20 @@ Runnable examples live in the repository under each crate's `examples/` director ## SerdesAI Integration -| Example | Description | Run | -| ------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| [serdesai-basic] | Minimal agent with file tools, shell execution, web fetch, and streaming output. | `cargo run --example serdesai-basic -p reloaded-code-serdesai` | -| [serdesai-agents] | Load markdown agents through `AgentLoader`, build a named agent via `AgentBuildContext` using the models.dev catalog. | `cargo run --example serdesai-agents -p reloaded-code-serdesai` | -| [serdesai-task] | Orchestrator delegates a read-only task to a reader sub-agent, with streamed transcript and tool-call logging. | `cargo run --example serdesai-task -p reloaded-code-serdesai` | -| [serdesai-sandboxed] | Agent with `AllowedPathResolver` - file operations restricted to specific directories. | `cargo run --example serdesai-sandboxed -p reloaded-code-serdesai` | -| [serdesai-sandboxed-bash] | Sandboxed shell execution with a bubblewrap `public_bot` profile (Linux only). | `cargo run --example serdesai-sandboxed-bash --features linux-bubblewrap -p reloaded-code-serdesai` | +| Example | Description | Run | +| --------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| [serdesai-basic] | Minimal agent with file tools, shell execution, web fetch, and streaming output. | `cargo run --example serdesai-basic -p reloaded-code-serdesai` | +| [serdesai-agents] | Load markdown agents through `AgentLoader`, build a named agent via `AgentBuildContext` using the models.dev catalog. | `cargo run --example serdesai-agents -p reloaded-code-serdesai` | +| [serdesai-custom-tool] | Register a portable custom tool, build a markdown agent with models.dev, and run it through SerdesAI. | `cargo run --example serdesai-custom-tool -p reloaded-code-serdesai` | +| [serdesai-custom-tool-standalone] | Portable custom tool attached directly to a SerdesAI `AgentBuilder` (no agent runtime). | `cargo run --example serdesai-custom-tool-standalone -p reloaded-code-serdesai` | +| [serdesai-task] | Orchestrator delegates a read-only task to a reader sub-agent, with streamed transcript and tool-call logging. | `cargo run --example serdesai-task -p reloaded-code-serdesai` | +| [serdesai-sandboxed] | Agent with `AllowedPathResolver` - file operations restricted to specific directories. | `cargo run --example serdesai-sandboxed -p reloaded-code-serdesai` | +| [serdesai-sandboxed-bash] | Sandboxed shell execution with a bubblewrap `public_bot` profile (Linux only). | `cargo run --example serdesai-sandboxed-bash --features linux-bubblewrap -p reloaded-code-serdesai` | [serdesai-basic]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/serdesai-basic.rs [serdesai-agents]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/serdesai-agents.rs +[serdesai-custom-tool]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/serdesai-custom-tool.rs +[serdesai-custom-tool-standalone]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/serdesai-custom-tool-standalone.rs [serdesai-task]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/serdesai-task.rs [serdesai-sandboxed]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/serdesai-sandboxed.rs [serdesai-sandboxed-bash]: https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/serdesai-sandboxed-bash.rs diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index 25bf8e4..7f02fbf 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -157,10 +157,12 @@ a Rust project and an LLM API key (e.g. `OPENAI_API_KEY`). ## Custom tools -Implement [`ToolContext`] and [`ToolFactory`], then register with the builder: +Implement a portable [`CustomTool`] plus [`ToolContext`] and [`ToolFactory`], +then register with the builder: ```rust struct MyFactory; +struct MyTool; impl ToolContext for MyFactory { fn name(&self) -> &'static str { "my_tool" } fn context(&self) -> ToolPrompt { @@ -168,11 +170,12 @@ impl ToolContext for MyFactory { } } impl ToolFactory for MyFactory { - fn create(&self, _ctx: &ToolBuildContext) -> Box { - todo!("return your tool") + fn create(&self, _ctx: &ToolBuildContext) -> ToolResult> { + Ok(Arc::new(MyTool)) } } +// MyTool implements CustomTool once; SerdesAI and other adapters wrap it. let runtime = AgentRuntimeBuilder::new() .custom_tool(MyFactory) .tools(vec![ @@ -184,9 +187,6 @@ let runtime = AgentRuntimeBuilder::new() See [Tools > Custom tools](tools.md#custom-tools) for annotated details and error handling. -[`ToolContext`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/trait.ToolContext.html -[`ToolFactory`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/trait.ToolFactory.html - ## Credential management `CredentialResolver` resolves API keys by name (e.g. `"OPENAI_API_KEY"`) - @@ -271,3 +271,6 @@ reloaded-code-core = { version = "0.2", default-features = false, features = ["b [SerdesAI]: https://crates.io/crates/serdes-ai [OpenCode]: https://opencode.ai/ [bubblewrap]: https://github.com/containers/bubblewrap +[`ToolContext`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/trait.ToolContext.html +[`CustomTool`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/trait.CustomTool.html +[`ToolFactory`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/trait.ToolFactory.html diff --git a/docs/src/guides/custom-framework.md b/docs/src/guides/custom-framework.md index a7dc8d8..4cac832 100644 --- a/docs/src/guides/custom-framework.md +++ b/docs/src/guides/custom-framework.md @@ -108,6 +108,28 @@ The builder includes guidance only for tracked tools. Cross-tool references (e.g. "prefer grep over read for searching") are included only when both tools are present. +## Portable custom tools + +Custom tools implement `CustomTool` in `reloaded-code-core`, not a framework +trait. Your adapter only needs a thin wrapper that: + +1. Converts `CustomToolDefinition` into your framework's tool definition type +2. Forwards JSON arguments to `CustomTool::call(ToolRunContext, args)` +3. Converts the returned `ToolOutput` into your framework's tool return type + +That means the same custom tool implementation can be registered once through +`ToolFactory` and reused by SerdesAI or any other Rust LLM framework adapter. + +!!! tip "Adapter example" + + See SerdesAI's + [`CustomToolAdapter`](https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/src/tools/custom.rs) + for a concrete adapter implementation, plus + [`serdesai-custom-tool`](../examples.md#serdesai-integration) for a runnable + portable custom tool example using the agent runtime, or + [`serdesai-custom-tool-standalone`](../examples.md#serdesai-integration) for a + direct `AgentBuilder` example without the runtime. + ## Step 3: Choose a path resolver | Resolver | Use when | @@ -156,7 +178,8 @@ let glob = AllowedGlobResolver::new(["/workspace/project"])? | `read_todos`, `write_todos` | Shared todo state | | `SystemPromptBuilder` | Context-aware system prompt generation | | `ToolContext` trait | Tool metadata interface for prompt building | -| `ToolFactory` / `CustomToolRegistry` | Framework-agnostic custom tool creation and lookup | +| `CustomTool` | Portable custom tool definition and execution | +| `ToolFactory` / `CustomToolRegistry` | Portable custom tool creation and lookup | | `ToolCatalogEntry` / `ToolCatalogKind` | Standard/custom tool catalog for adapters | | `PathResolver` trait | Path security boundary | | `AllowedPathResolver` | Directory-based sandbox | diff --git a/docs/src/tools.md b/docs/src/tools.md index 71da74f..0623b5c 100644 --- a/docs/src/tools.md +++ b/docs/src/tools.md @@ -127,12 +127,16 @@ Custom tools let embedders add non-built-in tools to an agent runtime. ```rust use reloaded_code_agents::AgentRuntimeBuilder; use reloaded_code_core::{ - ToolBuildContext, ToolCatalogEntry, ToolCatalogKind, ToolContext, ToolFactory, + CustomTool, CustomToolDefinition, CustomToolFuture, ToolBuildContext, + ToolCatalogEntry, ToolCatalogKind, ToolContext, ToolFactory, ToolOutput, + ToolResult, ToolRunContext, }; use reloaded_code_core::context::ToolPrompt; -use std::any::Any; +use serde_json::json; +use std::sync::Arc; struct WebSearchFactory; +struct WebSearchTool; // Name + prompt guidance. impl ToolContext for WebSearchFactory { @@ -142,11 +146,37 @@ impl ToolContext for WebSearchFactory { } } -// Build framework-specific tool instance. +// Build portable tool instance. impl ToolFactory for WebSearchFactory { - fn create(&self, _ctx: &ToolBuildContext) -> Box { - // SerdesAI: return Box::new(Box>). - todo!("return your tool") + fn create(&self, _ctx: &ToolBuildContext) -> ToolResult> { + Ok(Arc::new(WebSearchTool)) + } +} + +impl ToolContext for WebSearchTool { + fn name(&self) -> &'static str { "web_search" } + fn context(&self) -> ToolPrompt { + ToolPrompt::Static("Use web_search to find information online.") + } +} + +impl CustomTool for WebSearchTool { + fn definition(&self) -> CustomToolDefinition { + CustomToolDefinition::new("web_search", "Find information online") + .with_parameters(json!({ + "type": "object", + "properties": { + "query": { "type": "string", "description": "Search query" } + }, + "required": ["query"] + })) + } + + fn call<'a>(&'a self, _ctx: ToolRunContext<'a>, args: serde_json::Value) -> CustomToolFuture<'a> { + Box::pin(async move { + let query = args["query"].as_str().unwrap_or_default(); + Ok(ToolOutput::new(format!("searched for {query}"))) + }) } } @@ -163,10 +193,12 @@ let runtime = AgentRuntimeBuilder::new() Rules: - Factory name must match catalog entry name. +- Custom tool definition name must match catalog entry name. - `ToolContext::context()` adds system-prompt guidance. - Custom tool names work in agent `permission` maps. - Missing factory: `AgentBuildError::UnknownCustomTool`. -- Wrong return type: `AgentBuildError::CustomToolDowncastFailed`. +- Factory creation failure: `AgentBuildError::CustomToolCreateFailed`. +- Definition/catalog mismatch: `AgentBuildError::CustomToolNameMismatch`. See [reloaded-code-core API docs](https://docs.rs/reloaded-code-core/latest) for full API details. @@ -474,4 +506,4 @@ For a deeper dive into path security, see [Sandboxing](sandboxing.md). [bubblewrap]: https://github.com/containers/bubblewrap [create_todo_tools]: https://docs.rs/reloaded-code-serdesai/latest/reloaded_code_serdesai/tools/todo/fn.create_todo_tools.html [reloaded-code-core]: https://docs.rs/reloaded-code-core -[reloaded-code-serdesai]: https://docs.rs/reloaded-code-serdesai \ No newline at end of file +[reloaded-code-serdesai]: https://docs.rs/reloaded-code-serdesai diff --git a/src/reloaded-code-agents/README.md b/src/reloaded-code-agents/README.md index b856ea7..0716373 100644 --- a/src/reloaded-code-agents/README.md +++ b/src/reloaded-code-agents/README.md @@ -282,10 +282,11 @@ the factory on the builder: ```rust,no_run use reloaded_code_agents::AgentRuntimeBuilder; use reloaded_code_core::{ - ToolBuildContext, ToolCatalogEntry, ToolCatalogKind, ToolContext, ToolFactory, + CustomTool, ToolBuildContext, ToolCatalogEntry, ToolCatalogKind, ToolContext, + ToolFactory, ToolResult, }; use reloaded_code_core::context::ToolPrompt; -use std::any::Any; +use std::sync::Arc; struct WebSearchFactory; @@ -297,8 +298,8 @@ impl ToolContext for WebSearchFactory { } impl ToolFactory for WebSearchFactory { - fn create(&self, _ctx: &ToolBuildContext) -> Box { - todo!("return tool instance") + fn create(&self, _ctx: &ToolBuildContext) -> ToolResult> { + todo!("return portable custom tool instance") } } @@ -311,10 +312,11 @@ let runtime = AgentRuntimeBuilder::new() .tools(tools) .build()?; -# Ok::<(), reloaded_code_agents::AgentLoadError>(()) +# Ok::<(), reloaded_code_core::permissions::ExpandError>(()) ``` -`create()` returns `Box`. +`create()` returns a portable `Arc`. Framework adapters wrap +that object in their native tool trait. If a `ToolCatalogKind::Custom` entry has no matching factory, build returns `AgentBuildError::UnknownCustomTool`. @@ -365,4 +367,3 @@ For the internal architecture, see [ARCHITECTURE.md](https://github.com/Reloaded [API Reference]: https://docs.rs/reloaded-code-agents [`ToolFactory`]: https://docs.rs/reloaded_code_core/latest/reloaded_code_core/trait.ToolFactory.html [`ToolContext`]: https://docs.rs/reloaded_code_core/latest/reloaded_code_core/trait.ToolContext.html - diff --git a/src/reloaded-code-agents/src/path/resolver.rs b/src/reloaded-code-agents/src/path/resolver.rs index 52a87a4..ea7ab2c 100644 --- a/src/reloaded-code-agents/src/path/resolver.rs +++ b/src/reloaded-code-agents/src/path/resolver.rs @@ -286,7 +286,7 @@ mod tests { // Bare "**" -> workspace root (same as Action(Allow)). let expected = soft_canonicalize(temp.path())?; - assert_eq!(inner.allowed_paths(), &[expected.clone()]); + assert_eq!(inner.allowed_paths(), std::slice::from_ref(&expected)); // Any path within workspace should be allowed. assert!( diff --git a/src/reloaded-code-agents/src/runtime/builder.rs b/src/reloaded-code-agents/src/runtime/builder.rs index 6f2089c..e725cbd 100644 --- a/src/reloaded-code-agents/src/runtime/builder.rs +++ b/src/reloaded-code-agents/src/runtime/builder.rs @@ -109,10 +109,10 @@ mod tests { use reloaded_code_core::permissions::{ExpandError, PermissionAction}; use reloaded_code_core::tool_metadata::{glob as glob_meta, read as read_meta}; use reloaded_code_core::{ - default_tools, TaskSettings, ToolBuildContext, ToolCatalogEntry, ToolCatalogKind, - ToolFactory, + default_tools, CustomTool, CustomToolDefinition, CustomToolFuture, TaskSettings, + ToolBuildContext, ToolCatalogEntry, ToolCatalogKind, ToolFactory, ToolOutput, ToolResult, + ToolRunContext, }; - use std::any::Any; use std::sync::Arc; type TestResult = Result<(), ExpandError>; @@ -241,8 +241,40 @@ mod tests { } impl ToolFactory for TestFactory { - fn create(&self, _ctx: &ToolBuildContext) -> Box { - Box::new(()) + fn create(&self, _ctx: &ToolBuildContext) -> ToolResult> { + Ok(Arc::new(TestTool { + name: self.name, + prompt: self.prompt, + })) + } + } + + struct TestTool { + name: &'static str, + prompt: &'static str, + } + + impl ToolContext for TestTool { + fn name(&self) -> &'static str { + self.name + } + + fn context(&self) -> ToolPrompt { + ToolPrompt::Static(self.prompt) + } + } + + impl CustomTool for TestTool { + fn definition(&self) -> CustomToolDefinition { + CustomToolDefinition::new(self.name, "test tool") + } + + fn call<'a>( + &'a self, + _ctx: ToolRunContext<'a>, + _args: serde_json::Value, + ) -> CustomToolFuture<'a> { + Box::pin(async { Ok(ToolOutput::new("ok")) }) } } diff --git a/src/reloaded-code-core/README.md b/src/reloaded-code-core/README.md index d2a6911..4dd370d 100644 --- a/src/reloaded-code-core/README.md +++ b/src/reloaded-code-core/README.md @@ -275,8 +275,9 @@ Currently uses ~2000 tokens for full toolset, ~560 tokens for search-only. Core provides framework-agnostic plumbing for user-defined tools: -- [`ToolFactory`] - trait for building a tool from build-time context. Returns a - type-erased `Box` so adapter crates can downcast to their own tool trait. +- [`ToolFactory`] - builds a tool from context, returning a portable [`CustomTool`]. +- [`CustomToolDefinition`] - framework-neutral name, description, and JSON Schema. +- [`ToolRunContext`] - optional framework metadata for tool calls. - [`ToolBuildContext`] - shared build-time info passed to every factory (workspace root, permissions). Create once, reuse. - [`CustomToolRegistry`] - stores factories by tool name. Insert, lookup, iterate. @@ -286,9 +287,9 @@ Core provides framework-agnostic plumbing for user-defined tools: - [`ToolCatalogKind`] - enum listing every tool type (Read, Write, Bash, etc.). Adapters use this to know which tools to build. -Adapter crates downcast the `Box` returned by [`ToolFactory::create`] -to their framework's tool trait. This keeps core free of dependencies on any -specific LLM framework like SerdesAI. +Adapters wrap the [`CustomTool`] from [`ToolFactory::create`] in their +framework's native tool trait, keeping core framework-free and enabling reuse +across adapters. ## Permissions @@ -379,9 +380,12 @@ let key = resolver.resolve("OPENAI_API_KEY"); [`build(self)`]: crate::SystemPromptBuilder::build [`context`]: crate::context [`ToolContext`]: crate::context::ToolContext +[`CustomTool`]: crate::CustomTool +[`CustomToolDefinition`]: crate::CustomToolDefinition [`ToolFactory`]: crate::ToolFactory [`ToolFactory::create`]: crate::ToolFactory::create [`ToolBuildContext`]: crate::ToolBuildContext +[`ToolRunContext`]: crate::ToolRunContext [`CustomToolRegistry`]: crate::CustomToolRegistry [`SharedToolRegistry`]: crate::SharedToolRegistry [`ToolCatalogEntry`]: crate::ToolCatalogEntry diff --git a/src/reloaded-code-core/src/custom_tool/definition.rs b/src/reloaded-code-core/src/custom_tool/definition.rs new file mode 100644 index 0000000..9262c1a --- /dev/null +++ b/src/reloaded-code-core/src/custom_tool/definition.rs @@ -0,0 +1,95 @@ +//! Framework-neutral custom tool definitions. + +use serde::{Deserialize, Serialize}; + +/// Model-facing definition for a custom tool. +/// +/// This mirrors the common function-calling shape used by LLM frameworks while +/// staying independent from any specific adapter crate. Framework adapters are +/// expected to translate this type to their native tool definition type. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct CustomToolDefinition { + /// Tool name exposed to the model. + pub name: String, + /// Human-readable description of what the tool does. + pub description: String, + /// JSON Schema object describing accepted arguments. + pub parameters_json_schema: serde_json::Value, + /// Optional strict-schema flag for frameworks/providers that support it. + #[serde(skip_serializing_if = "Option::is_none")] + pub strict: Option, +} + +impl CustomToolDefinition { + /// Creates a definition with an empty object parameter schema. + #[must_use] + pub fn new(name: impl Into, description: impl Into) -> Self { + Self { + name: name.into(), + description: description.into(), + parameters_json_schema: empty_object_schema(), + strict: None, + } + } + + /// Replaces the parameters JSON Schema. + #[must_use] + pub fn with_parameters(mut self, schema: impl Into) -> Self { + self.parameters_json_schema = schema.into(); + self + } + + /// Sets the optional strict-schema flag. + #[must_use] + pub fn with_strict(mut self, strict: bool) -> Self { + self.strict = Some(strict); + self + } +} + +fn empty_object_schema() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": {}, + "additionalProperties": false, + }) +} + +#[cfg(test)] +mod tests { + use super::CustomToolDefinition; + use serde_json::json; + + #[test] + fn definition_defaults_to_empty_object_schema() { + let definition = CustomToolDefinition::new("echo", "Echoes input"); + + assert_eq!(definition.name, "echo"); + assert_eq!(definition.description, "Echoes input"); + assert_eq!(definition.parameters_json_schema["type"], "object"); + assert_eq!(definition.parameters_json_schema["properties"], json!({})); + assert_eq!( + definition.parameters_json_schema["additionalProperties"], + false + ); + assert_eq!(definition.strict, None); + } + + #[test] + fn definition_accepts_custom_schema_and_strict_flag() { + let schema = json!({ + "type": "object", + "properties": { + "message": { "type": "string" } + }, + "required": ["message"] + }); + + let definition = CustomToolDefinition::new("echo", "Echoes input") + .with_parameters(schema.clone()) + .with_strict(true); + + assert_eq!(definition.parameters_json_schema, schema); + assert_eq!(definition.strict, Some(true)); + } +} diff --git a/src/reloaded-code-core/src/custom_tool/factory.rs b/src/reloaded-code-core/src/custom_tool/factory.rs index 64ff1bc..a8c8a2a 100644 --- a/src/reloaded-code-core/src/custom_tool/factory.rs +++ b/src/reloaded-code-core/src/custom_tool/factory.rs @@ -1,49 +1,31 @@ //! Trait and context for creating tools at build time. +use super::CustomTool; use crate::context::ToolContext; use crate::tool_context::ToolBuildContext; -use std::any::Any; +use crate::ToolResult; +use std::sync::Arc; /// Build-time factory for a user-defined tool. /// /// Implement this to register a tool. The same type also acts as [`ToolContext`], /// supplying the tool's identity and prompt. /// -/// The [`ToolFactory::create()`] method returns a type-erased boxed value. -/// Adapter crates downcast it to the framework-specific tool trait they expect. +/// The [`ToolFactory::create()`] method returns a portable custom tool trait +/// object. Adapter crates wrap that object in the framework-specific tool trait +/// they expect. /// -/// # Example -/// -/// ```rust -/// use reloaded_code_core::{ToolBuildContext, ToolFactory}; -/// use reloaded_code_core::context::{ToolContext, ToolPrompt}; -/// use std::any::Any; -/// -/// struct WebSearchFactory; -/// -/// struct WebSearchTool; -/// impl WebSearchTool { -/// fn new(_ctx: &ToolBuildContext) -> Self { Self } -/// } -/// -/// impl ToolContext for WebSearchFactory { -/// fn name(&self) -> &'static str { "web_search" } -/// fn context(&self) -> ToolPrompt { -/// ToolPrompt::Static("Use web_search to find information online.") -/// } -/// } -/// -/// impl ToolFactory for WebSearchFactory { -/// fn create(&self, ctx: &ToolBuildContext) -> Box { -/// Box::new(WebSearchTool::new(ctx)) -/// } -/// } -/// ``` +/// For a complete example that implements [`ToolContext`], [`CustomTool`], and +/// [`ToolFactory`], then registers the factory with +/// [`CustomToolRegistry`](super::CustomToolRegistry), see the +/// [`custom_tool`](super) module documentation. pub trait ToolFactory: ToolContext + Send + Sync + 'static { /// Creates a tool from build-time context. /// - /// Return a [`Box`] wrapping the concrete tool value - /// or framework-specific boxed trait object. Adapter crates decide the - /// expected downcast type. - fn create(&self, ctx: &ToolBuildContext) -> Box; + /// Return a portable [`CustomTool`] trait object. Adapter crates wrap the + /// object in their native framework-specific tool type. + /// + /// # Errors + /// Returns a [`ToolError`](crate::ToolError) when constructing the tool fails. + fn create(&self, ctx: &ToolBuildContext) -> ToolResult>; } diff --git a/src/reloaded-code-core/src/custom_tool/mod.rs b/src/reloaded-code-core/src/custom_tool/mod.rs index 5cdbf0f..cbcd31d 100644 --- a/src/reloaded-code-core/src/custom_tool/mod.rs +++ b/src/reloaded-code-core/src/custom_tool/mod.rs @@ -1,11 +1,14 @@ //! Custom tool registration primitives. //! -//! Embedders implement [`ToolFactory`] to provide custom tools that integrate -//! with framework adapters, permission rules, and system prompt builders without -//! depending on an agent runtime. +//! Embedders implement [`CustomTool`] and [`ToolFactory`] to provide portable +//! custom tools that integrate with framework adapters, permission rules, and +//! system prompt builders without depending on a specific LLM framework. //! //! # Public API //! +//! - [`CustomTool`] - Framework-neutral trait for tool definition and execution. +//! - [`CustomToolDefinition`] - Framework-neutral name, description, and schema. +//! - [`ToolRunContext`] - Optional framework metadata passed to tool calls. //! - [`ToolFactory`] - Trait for creating custom tools at build time. Extends //! [`ToolContext`](crate::ToolContext) so factories provide name and prompt //! guidance the same way built-in tools do. @@ -17,12 +20,17 @@ //! # Usage //! //! ```rust -//! use reloaded_code_core::{CustomToolRegistry, ToolBuildContext, ToolFactory}; +//! use reloaded_code_core::{CustomTool, CustomToolDefinition, CustomToolFuture, CustomToolRegistry, ToolBuildContext, ToolFactory, ToolOutput, ToolResult, ToolRunContext}; //! use reloaded_code_core::context::{ToolContext, ToolPrompt}; -//! use std::any::Any; +//! use serde_json::json; +//! use std::sync::Arc; //! //! struct MyFactory; +//! //! struct MyTool; +//! impl MyTool { +//! fn new(_ctx: &ToolBuildContext) -> Self { Self } +//! } //! //! impl ToolContext for MyFactory { //! fn name(&self) -> &'static str { "my_tool" } @@ -31,9 +39,36 @@ //! } //! } //! +//! impl ToolContext for MyTool { +//! fn name(&self) -> &'static str { "my_tool" } +//! fn context(&self) -> ToolPrompt { +//! ToolPrompt::Static("Use my_tool to do things.") +//! } +//! } +//! +//! impl CustomTool for MyTool { +//! fn definition(&self) -> CustomToolDefinition { +//! CustomToolDefinition::new("my_tool", "Does things") +//! .with_parameters(json!({ +//! "type": "object", +//! "properties": { +//! "query": { "type": "string", "description": "Search query" } +//! }, +//! "required": ["query"] +//! })) +//! } +//! +//! fn call<'a>(&'a self, _ctx: ToolRunContext<'a>, args: serde_json::Value) -> CustomToolFuture<'a> { +//! Box::pin(async move { +//! let query = args["query"].as_str().unwrap_or_default(); +//! Ok(ToolOutput::new(format!("searched for {query}"))) +//! }) +//! } +//! } +//! //! impl ToolFactory for MyFactory { -//! fn create(&self, _ctx: &ToolBuildContext) -> Box { -//! Box::new(MyTool) +//! fn create(&self, ctx: &ToolBuildContext) -> ToolResult> { +//! Ok(Arc::new(MyTool::new(ctx))) //! } //! } //! @@ -42,12 +77,18 @@ //! assert!(registry.get("my_tool").is_some()); //! ``` +pub(crate) mod definition; pub(crate) mod factory; pub(crate) mod registry; +pub(crate) mod runtime; +pub(crate) mod tool; pub use crate::tool_context::ToolBuildContext; +pub use definition::CustomToolDefinition; pub use factory::ToolFactory; pub use registry::{CustomToolRegistry, SharedToolRegistry}; +pub use runtime::ToolRunContext; +pub use tool::{CustomTool, CustomToolFuture}; #[cfg(test)] pub(crate) mod test_stubs; @@ -87,12 +128,13 @@ mod tests { } #[test] - fn factory_create_returns_boxed_value() { + fn factory_create_returns_portable_tool() { let factory = EchoFactory::new("echo"); let ctx = ToolBuildContext::new(std::path::Path::new("/tmp"), None).unwrap(); - let boxed = factory.create(&ctx); - let value = boxed.downcast::().expect("should downcast to usize"); - assert_eq!(*value, 42); + let tool = factory.create(&ctx).expect("factory should create tool"); + + assert_eq!(tool.name(), "echo"); + assert_eq!(tool.definition().name, "echo"); } #[test] diff --git a/src/reloaded-code-core/src/custom_tool/runtime.rs b/src/reloaded-code-core/src/custom_tool/runtime.rs new file mode 100644 index 0000000..2617cc5 --- /dev/null +++ b/src/reloaded-code-core/src/custom_tool/runtime.rs @@ -0,0 +1,90 @@ +//! Runtime types for portable custom tool calls. + +/// Framework-neutral metadata available when a custom tool is called. +/// +/// Framework adapters populate whichever fields their runtime exposes. Custom +/// tools should treat every field as optional so the same implementation can run +/// across adapters with different context capabilities. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ToolRunContext<'a> { + model_name: Option<&'a str>, + run_id: Option<&'a str>, + tool_call_id: Option<&'a str>, +} + +impl<'a> ToolRunContext<'a> { + /// Creates an empty context. + #[must_use] + pub const fn new() -> Self { + Self { + model_name: None, + run_id: None, + tool_call_id: None, + } + } + + /// Adds the model name supplied by the framework, if any. + #[must_use] + pub const fn with_model_name(mut self, model_name: &'a str) -> Self { + self.model_name = Some(model_name); + self + } + + /// Adds the framework run identifier, if any. + #[must_use] + pub const fn with_run_id(mut self, run_id: &'a str) -> Self { + self.run_id = Some(run_id); + self + } + + /// Adds the framework tool-call identifier, if any. + #[must_use] + pub const fn with_tool_call_id(mut self, tool_call_id: &'a str) -> Self { + self.tool_call_id = Some(tool_call_id); + self + } + + /// Returns the model name supplied by the framework, if any. + #[must_use] + pub const fn model_name(&self) -> Option<&'a str> { + self.model_name + } + + /// Returns the framework run identifier, if any. + #[must_use] + pub const fn run_id(&self) -> Option<&'a str> { + self.run_id + } + + /// Returns the framework tool-call identifier, if any. + #[must_use] + pub const fn tool_call_id(&self) -> Option<&'a str> { + self.tool_call_id + } +} + +#[cfg(test)] +mod tests { + use super::ToolRunContext; + + #[test] + fn context_starts_empty() { + let ctx = ToolRunContext::new(); + + assert_eq!(ctx.model_name(), None); + assert_eq!(ctx.run_id(), None); + assert_eq!(ctx.tool_call_id(), None); + } + + #[test] + fn context_records_framework_metadata() { + let ctx = ToolRunContext::new() + .with_model_name("model") + .with_run_id("run") + .with_tool_call_id("call"); + + assert_eq!(ctx.model_name(), Some("model")); + assert_eq!(ctx.run_id(), Some("run")); + assert_eq!(ctx.tool_call_id(), Some("call")); + } +} diff --git a/src/reloaded-code-core/src/custom_tool/test_stubs.rs b/src/reloaded-code-core/src/custom_tool/test_stubs.rs index 560be17..5d83eea 100644 --- a/src/reloaded-code-core/src/custom_tool/test_stubs.rs +++ b/src/reloaded-code-core/src/custom_tool/test_stubs.rs @@ -1,8 +1,9 @@ //! Shared test stubs for custom tool tests. -use super::{ToolBuildContext, ToolFactory}; +use super::{CustomTool, CustomToolDefinition, CustomToolFuture, ToolBuildContext, ToolFactory}; use crate::context::{ToolContext, ToolPrompt}; -use std::any::Any; +use crate::{ToolOutput, ToolResult}; +use std::sync::Arc; /// Minimal factory returning a configurable prompt and empty boxed value. pub(crate) struct TestFactory { @@ -30,12 +31,15 @@ impl ToolContext for TestFactory { } impl ToolFactory for TestFactory { - fn create(&self, _ctx: &ToolBuildContext) -> Box { - Box::new(()) + fn create(&self, _ctx: &ToolBuildContext) -> ToolResult> { + Ok(Arc::new(TestTool { + tool_name: self.tool_name, + prompt: self.prompt, + })) } } -/// Factory that returns a downcastable integer for registry tests. +/// Factory that returns a portable echo tool for registry tests. pub(crate) struct EchoFactory { /// Tool name passed to [`ToolContext::name`]. pub(crate) tool_name: &'static str, @@ -59,7 +63,40 @@ impl ToolContext for EchoFactory { } impl ToolFactory for EchoFactory { - fn create(&self, _ctx: &ToolBuildContext) -> Box { - Box::new(42_usize) + fn create(&self, _ctx: &ToolBuildContext) -> ToolResult> { + Ok(Arc::new(TestTool { + tool_name: self.tool_name, + prompt: "echo tool prompt", + })) + } +} + +/// Minimal portable custom tool used by factories above. +struct TestTool { + tool_name: &'static str, + prompt: &'static str, +} + +impl ToolContext for TestTool { + fn name(&self) -> &'static str { + self.tool_name + } + + fn context(&self) -> ToolPrompt { + ToolPrompt::Static(self.prompt) + } +} + +impl CustomTool for TestTool { + fn definition(&self) -> CustomToolDefinition { + CustomToolDefinition::new(self.tool_name, "test custom tool") + } + + fn call<'a>( + &'a self, + _ctx: super::ToolRunContext<'a>, + _args: serde_json::Value, + ) -> CustomToolFuture<'a> { + Box::pin(async { Ok(ToolOutput::new("ok")) }) } } diff --git a/src/reloaded-code-core/src/custom_tool/tool.rs b/src/reloaded-code-core/src/custom_tool/tool.rs new file mode 100644 index 0000000..3c415e8 --- /dev/null +++ b/src/reloaded-code-core/src/custom_tool/tool.rs @@ -0,0 +1,54 @@ +//! Portable custom tool trait. + +use super::{CustomToolDefinition, ToolRunContext}; +use crate::context::ToolContext; +use crate::{ToolOutput, ToolResult}; +use std::future::Future; +use std::pin::Pin; + +/// Boxed future returned by [`CustomTool::call`]. +pub type CustomToolFuture<'a> = Pin> + Send + 'a>>; + +/// Framework-neutral custom tool implementation. +/// +/// Implement this once for a custom tool, then let framework adapters wrap the +/// trait object in their native tool trait. This keeps the tool definition, +/// prompt guidance, argument schema, and execution logic portable. +pub trait CustomTool: ToolContext + Send + Sync + 'static { + /// Returns the model-facing definition for this tool. + #[must_use] + fn definition(&self) -> CustomToolDefinition; + + /// Executes the tool with JSON arguments from the model. + /// + /// The returned [`ToolOutput`] is framework-neutral; adapters convert it to + /// the native return type expected by their LLM framework. + /// + /// # Errors + /// + /// Returns a [`ToolError`]. Common call failures include: + /// + /// - [`ToolError::Validation`] for malformed arguments. + /// - [`ToolError::Io`] on filesystem failures. + /// - [`ToolError::Execution`] for command execution failures. + /// - [`ToolError::Http`] for network request failures. + /// - [`ToolError::Json`] for serialization or deserialization failures. + /// - [`ToolError::Timeout`] and [`ToolError::TimeoutWithKillFailure`] + /// when execution time limits are exceeded. + /// - [`ToolError::PermissionDenied`] when access to the requested + /// resource is denied. + /// + /// See [`ToolError`] for additional variants. + /// + /// [`ToolError`]: crate::ToolError + /// [`ToolError::Validation`]: crate::ToolError::Validation + /// [`ToolError::Io`]: crate::ToolError::Io + /// [`ToolError::Execution`]: crate::ToolError::Execution + /// [`ToolError::Http`]: crate::ToolError::Http + /// [`ToolError::Json`]: crate::ToolError::Json + /// [`ToolError::Timeout`]: crate::ToolError::Timeout + /// [`ToolError::TimeoutWithKillFailure`]: crate::ToolError::TimeoutWithKillFailure + /// [`ToolError::PermissionDenied`]: crate::ToolError::PermissionDenied + fn call<'a>(&'a self, ctx: ToolRunContext<'a>, args: serde_json::Value) + -> CustomToolFuture<'a>; +} diff --git a/src/reloaded-code-core/src/lib.rs b/src/reloaded-code-core/src/lib.rs index ebd5657..5cbacdb 100644 --- a/src/reloaded-code-core/src/lib.rs +++ b/src/reloaded-code-core/src/lib.rs @@ -29,7 +29,10 @@ mod internal; pub use context::ToolContext; pub use credentials::{CredentialLookup, CredentialResolver}; -pub use custom_tool::{CustomToolRegistry, SharedToolRegistry, ToolBuildContext, ToolFactory}; +pub use custom_tool::{ + CustomTool, CustomToolDefinition, CustomToolFuture, CustomToolRegistry, SharedToolRegistry, + ToolBuildContext, ToolFactory, ToolRunContext, +}; pub use error::{ToolError, ToolResult}; pub use output::ToolOutput; pub use path::{AbsolutePathResolver, AllowedGlobResolver, AllowedPathResolver, PathResolver}; diff --git a/src/reloaded-code-core/src/system_prompt.rs b/src/reloaded-code-core/src/system_prompt.rs index df5e5db..52cd1ef 100644 --- a/src/reloaded-code-core/src/system_prompt.rs +++ b/src/reloaded-code-core/src/system_prompt.rs @@ -81,30 +81,8 @@ impl SystemPromptBuilder { /// Records context and returns tool unchanged. /// - /// Use this to wrap tools before registering them with your tool collection: - /// ```no_run - /// use reloaded_code_core::context::{PathMode, ToolContext, ToolPrompt}; - /// use reloaded_code_core::SystemPromptBuilder; - /// - /// struct MyTool; - /// - /// impl ToolContext for MyTool { - /// fn name(&self) -> &'static str { - /// "read" - /// } - /// - /// fn context(&self) -> ToolPrompt { - /// ToolPrompt::Read { - /// path_mode: PathMode::Absolute, - /// line_numbers: true, - /// } - /// } - /// } - /// - /// let mut pb = SystemPromptBuilder::new(); - /// let _my_tool = pb.track(MyTool); - /// // register _my_tool with your tool collection - /// ``` + /// Use this to wrap tools before registering them with your tool collection. + /// See [`SystemPromptBuilder`] for a full `track()` usage demonstration. /// /// For example, if working with serdesAI: /// ```text diff --git a/src/reloaded-code-serdesai/README.md b/src/reloaded-code-serdesai/README.md index 02d9c87..aab61e3 100644 --- a/src/reloaded-code-serdesai/README.md +++ b/src/reloaded-code-serdesai/README.md @@ -164,41 +164,105 @@ See [examples/serdesai-agents.rs](examples/serdesai-agents.rs) and ## Custom tools -Register custom tools that integrate with the SerdesAI agent builder. Your -tool must implement `serdes_ai::Tool<()>` and be wrapped by a core -[`ToolFactory`]: +Define a portable [`CustomTool`] once (depends only on `reloaded-code-core`), +then attach it either directly or via the agent runtime. ```rust,no_run -use reloaded_code_agents::AgentRuntimeBuilder; -use reloaded_code_core::{ToolBuildContext, ToolCatalogEntry, ToolCatalogKind, ToolContext, ToolFactory}; -use reloaded_code_core::context::ToolPrompt; -use serdes_ai::tools::{RunContext, SchemaBuilder, Tool, ToolDefinition, ToolResult, ToolReturn}; -use std::any::Any; -use async_trait::async_trait; +use reloaded_code_core::{ + CustomTool, CustomToolDefinition, CustomToolFuture, ToolOutput, + ToolRunContext, ToolContext, context::ToolPrompt, +}; +use serde_json::json; +use std::sync::Arc; -// 1. Define the tool - implement Tool<()> with a definition and call handler struct EchoTool; -#[async_trait] -impl Tool<()> for EchoTool { - fn definition(&self) -> ToolDefinition { - // For tools without parameters, just use ToolDefinition::new(name, description) - ToolDefinition::new("echo", "Echo a message back") - .with_parameters( - SchemaBuilder::new() - .string("message", "Message to echo", true) - .build() - .unwrap(), - ) +impl ToolContext for EchoTool { + fn name(&self) -> &'static str { "echo" } + fn context(&self) -> ToolPrompt { + ToolPrompt::Static("Use echo to repeat a message.") } +} - async fn call(&self, _ctx: &RunContext<()>, args: serde_json::Value) -> ToolResult { - let msg = args["message"].as_str().unwrap_or_default(); - Ok(ToolReturn::text(msg)) +impl CustomTool for EchoTool { + fn definition(&self) -> CustomToolDefinition { + CustomToolDefinition::new("echo", "Echo a message back") + .with_parameters(json!({ + "type": "object", + "properties": { + "message": { "type": "string", "description": "Message to echo" } + }, + "required": ["message"] + })) + } + + fn call<'a>(&'a self, _ctx: ToolRunContext<'a>, args: serde_json::Value) -> CustomToolFuture<'a> { + Box::pin(async move { + let msg = args["message"].as_str().unwrap_or_default(); + Ok(ToolOutput::new(msg)) + }) } } +``` + +### Direct attachment (no agent runtime) + +Wrap with [`CustomToolAdapter`] and attach to a plain SerdesAI agent: + +```rust,no_run +use reloaded_code_serdesai::{CustomToolAdapter, SystemPromptBuilder}; +use reloaded_code_serdesai::agent_ext::AgentBuilderExt; +use serdes_ai::prelude::*; +# use reloaded_code_core::{CustomTool, CustomToolDefinition, CustomToolFuture, ToolOutput, +# ToolRunContext, ToolContext, context::ToolPrompt}; +# use serde_json::json; +# use std::sync::Arc; +# struct EchoTool; +# impl ToolContext for EchoTool { +# fn name(&self) -> &'static str { "echo" } +# fn context(&self) -> ToolPrompt { ToolPrompt::Static("") } +# } +# impl CustomTool for EchoTool { +# fn definition(&self) -> CustomToolDefinition { CustomToolDefinition::new("echo", "") } +# fn call<'a>(&'a self, _: ToolRunContext<'a>, _: serde_json::Value) -> CustomToolFuture<'a> { +# Box::pin(async { Ok(ToolOutput::new("")) }) +# } +# } + +let mut pb = SystemPromptBuilder::new(); +let agent = AgentBuilder::<(), String>::from_model("openai:gpt-5.4")? + .tool(pb.track(CustomToolAdapter::new(Arc::new(EchoTool)))) + .system_prompt(pb.build()) + .build(); +# Ok::<(), Box>(()) +``` + +### Agent runtime registration + +Register a factory with [`AgentRuntimeBuilder`]. The build layer wraps the +portable tool automatically: + +```rust,no_run +use reloaded_code_agents::AgentRuntimeBuilder; +use reloaded_code_core::{ + CustomTool, ToolBuildContext, ToolCatalogEntry, ToolCatalogKind, + ToolContext, ToolFactory, ToolResult, context::ToolPrompt, +}; +use std::sync::Arc; +# use reloaded_code_core::{CustomToolDefinition, CustomToolFuture, ToolOutput, ToolRunContext}; +# use serde_json::json; +# struct EchoTool; +# impl ToolContext for EchoTool { +# fn name(&self) -> &'static str { "echo" } +# fn context(&self) -> ToolPrompt { ToolPrompt::Static("") } +# } +# impl CustomTool for EchoTool { +# fn definition(&self) -> CustomToolDefinition { CustomToolDefinition::new("echo", "") } +# fn call<'a>(&'a self, _: ToolRunContext<'a>, _: serde_json::Value) -> CustomToolFuture<'a> { +# Box::pin(async { Ok(ToolOutput::new("")) }) +# } +# } -// 2. Provide name and prompt guidance via ToolContext struct EchoFactory; impl ToolContext for EchoFactory { fn name(&self) -> &'static str { "echo" } @@ -207,40 +271,34 @@ impl ToolContext for EchoFactory { } } -// 3. Create the tool at build time via ToolFactory impl ToolFactory for EchoFactory { - fn create(&self, _ctx: &ToolBuildContext) -> Box { - Box::new(Box::new(EchoTool) as Box>) + fn create(&self, _ctx: &ToolBuildContext) -> ToolResult> { + Ok(Arc::new(EchoTool)) } } -// 4. Register and build -fn main() -> Result<(), Box> { - let tools = vec![ - // ...existing tools... - ToolCatalogEntry::new("echo", ToolCatalogKind::Custom), - ]; - - let runtime = AgentRuntimeBuilder::new() - .custom_tool(EchoFactory) - .tools(tools) - .build()?; - Ok(()) -} +let tools = vec![ + ToolCatalogEntry::new("echo", ToolCatalogKind::Custom), +]; + +let runtime = AgentRuntimeBuilder::new() + .custom_tool(EchoFactory) + .tools(tools) + .build()?; +# Ok::<(), reloaded_code_core::permissions::ExpandError>(()) ``` The SerdesAI build layer automatically: -1. Looks up the factory by name in the custom tool registry -2. Calls `create()` with a shared `ToolBuildContext` (workspace root + permissions) -3. Downcasts the type-erased return to `Box>` +1. Looks up the factory by name in the registry +2. Calls `create()` with a `ToolBuildContext` (workspace root + permissions) +3. Wraps the returned `CustomTool` as a SerdesAI tool 4. Registers prompt guidance via `SystemPromptBuilder::track_entry()` 5. Attaches the tool to the agent builder -If a catalog entry references a custom tool with no registered factory, the -build returns `AgentBuildError::UnknownCustomTool`. If `create()` returns a -value that cannot be downcast, it returns -`AgentBuildError::CustomToolDowncastFailed`. +Errors: missing factory → `AgentBuildError::UnknownCustomTool`, +`create()` failure → `AgentBuildError::CustomToolCreateFailed`, +name mismatch → `AgentBuildError::CustomToolNameMismatch`. ## Linux Shell Sandboxing @@ -283,6 +341,9 @@ cargo run --example serdesai-sandboxed-bash --features linux-bubblewrap -p reloa # Markdown agent runtime (shared build context) cargo run --example serdesai-agents -p reloaded-code-serdesai +# Portable custom tool with models.dev catalog +cargo run --example serdesai-custom-tool -p reloaded-code-serdesai + # Stateless single-hop Task delegation cargo run --example serdesai-task -p reloaded-code-serdesai ``` @@ -299,3 +360,4 @@ Apache 2.0 [Documentation]: https://reloaded-project.github.io/ReloadedCode/ [API Reference]: https://docs.rs/reloaded-code-serdesai [`ToolFactory`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/trait.ToolFactory.html +[`CustomTool`]: https://docs.rs/reloaded-code-core/latest/reloaded_code_core/trait.CustomTool.html diff --git a/src/reloaded-code-serdesai/examples/agents/custom-tool/custom-tool-demo.md b/src/reloaded-code-serdesai/examples/agents/custom-tool/custom-tool-demo.md new file mode 100644 index 0000000..366a9c3 --- /dev/null +++ b/src/reloaded-code-serdesai/examples/agents/custom-tool/custom-tool-demo.md @@ -0,0 +1,13 @@ +--- +name: custom-tool-demo +mode: all +description: Demonstrates a runtime-registered portable custom tool. +permission: + project_info: allow + read: allow + task: deny +--- + +You are the `custom-tool-demo` agent. +Use the `project_info` custom tool when the user asks about repository or demo metadata. +Keep the final answer concise and mention what the custom tool returned. diff --git a/src/reloaded-code-serdesai/examples/serdesai-custom-tool-standalone.rs b/src/reloaded-code-serdesai/examples/serdesai-custom-tool-standalone.rs new file mode 100644 index 0000000..68a4a47 --- /dev/null +++ b/src/reloaded-code-serdesai/examples/serdesai-custom-tool-standalone.rs @@ -0,0 +1,152 @@ +//! Standalone portable custom tool example (no AgentRuntime). +//! +//! Demonstrates using a portable `CustomTool` with a plain SerdesAI agent +//! builder, without the agent catalog / runtime infrastructure. +//! +//! The custom tool depends only on `reloaded-code-core`. The SerdesAI +//! [`CustomToolAdapter`] wraps it so it can be attached via +//! [`AgentBuilderExt::tool`]. +//! +//! Run: Edit the `API_KEY` constant below, or set the `OPENAI_API_KEY` +//! environment variable, then: +//! cargo run --example serdesai-custom-tool-standalone -p reloaded-code-serdesai + +use futures::StreamExt; +use reloaded_code_core::context::{ToolContext, ToolPrompt}; +use reloaded_code_core::{ + CustomTool, CustomToolDefinition, CustomToolFuture, ToolOutput, ToolRunContext, +}; +use reloaded_code_serdesai::SystemPromptBuilder; +use reloaded_code_serdesai::agent_ext::AgentBuilderExt; +use reloaded_code_serdesai::tools::CustomToolAdapter; +use serde_json::json; +use serdes_ai::prelude::*; +use serdes_ai_models::OpenAIChatModel; +use std::fmt::Write; +use std::sync::Arc; + +const MODEL_ID: &str = "hf:zai-org/GLM-4.7-Flash"; +const BASE_URL: &str = "https://api.synthetic.new/openai/v1"; +/// Fallback API key if env var is not set. Leave empty to require env var. +const API_KEY: &str = ""; + +fn get_api_key() -> String { + std::env::var("OPENAI_API_KEY").unwrap_or_else(|_| API_KEY.to_string()) +} + +// -- Portable custom tool (depends only on reloaded-code-core) -- + +struct ProjectInfoTool; + +impl ToolContext for ProjectInfoTool { + fn name(&self) -> &'static str { + "project_info" + } + + fn context(&self) -> ToolPrompt { + ToolPrompt::Static("Use project_info to get demo metadata about this host application.") + } +} + +impl CustomTool for ProjectInfoTool { + fn definition(&self) -> CustomToolDefinition { + CustomToolDefinition::new( + "project_info", + "Returns host-provided metadata about the running environment.", + ) + .with_parameters(json!({ + "type": "object", + "properties": { + "include_cwd": { + "type": "boolean", + "description": "Include the current working directory." + } + }, + "additionalProperties": false + })) + } + + fn call<'a>( + &'a self, + ctx: ToolRunContext<'a>, + args: serde_json::Value, + ) -> CustomToolFuture<'a> { + Box::pin(async move { + let include_cwd = args + .get("include_cwd") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + + let mut lines = vec![ + format!( + "called_by_model: {}", + ctx.model_name().unwrap_or("") + ), + format!("run_id_present: {}", ctx.run_id().is_some()), + format!("tool_call_id_present: {}", ctx.tool_call_id().is_some()), + ]; + + if include_cwd { + lines.push(format!( + "cwd: {}", + std::env::current_dir() + .map(|p| p.display().to_string()) + .unwrap_or_default() + )); + } + + Ok(ToolOutput::new(lines.join("\n"))) + }) + } +} + +// -- Main -- + +#[tokio::main] +async fn main() -> std::result::Result<(), Box> { + let model = OpenAIChatModel::new(MODEL_ID, get_api_key()).with_base_url(BASE_URL); + + let mut pb = SystemPromptBuilder::new() + .working_directory(std::env::current_dir()?.display().to_string()); + + let agent = AgentBuilder::<(), String>::new(model) + .instructions("Call project_info with include_cwd=true, then summarize in three bullets.") + .tool(pb.track(CustomToolAdapter::new(Arc::new(ProjectInfoTool)))) + .system_prompt(pb.build()) + .build(); + + println!("Agent ready ({} tools).", agent.tools().len()); + + let prompt = + "Call project_info with include_cwd=true, then summarize what it says in three bullets."; + let mut stream = agent.run_stream(prompt, ()).await?; + + fn log_xml(request_id: u32, tag: &str, content: &str) { + let mut line = String::with_capacity(content.len() + tag.len() * 2 + 18); + let _ = write!(line, "<{request_id}:{tag}>{content}"); + println!("{line}"); + } + + let mut request_id = 0u32; + log_xml(request_id, "user", prompt); + request_id = request_id.saturating_add(1); + let mut assistant_message = String::with_capacity(256); + + while let Some(event) = stream.next().await { + match event? { + AgentStreamEvent::TextDelta { text, .. } => assistant_message.push_str(&text), + AgentStreamEvent::RequestStart { .. } => assistant_message.clear(), + AgentStreamEvent::ToolCallStart { tool_name, .. } => { + log_xml(request_id, "tool", &tool_name); + request_id = request_id.saturating_add(1); + } + AgentStreamEvent::ResponseComplete { .. } => { + log_xml(request_id, "assistant", &assistant_message); + request_id = request_id.saturating_add(1); + } + _ => {} + } + } + + Ok(()) +} diff --git a/src/reloaded-code-serdesai/examples/serdesai-custom-tool.rs b/src/reloaded-code-serdesai/examples/serdesai-custom-tool.rs new file mode 100644 index 0000000..1a08293 --- /dev/null +++ b/src/reloaded-code-serdesai/examples/serdesai-custom-tool.rs @@ -0,0 +1,194 @@ +//! Portable custom tool example using the models.dev catalog. +//! +//! Loads a markdown agent, registers a framework-neutral custom tool through +//! [`AgentRuntimeBuilder`], builds the SerdesAI agent with [`AgentBuildContext`], +//! and runs a prompt that should call the custom tool. +//! +//! Run: Edit the API_KEY_NAME and API_KEY_VALUE constants below, then: +//! cargo run --example serdesai-custom-tool -p reloaded-code-serdesai + +use reloaded_code_agents::{AgentCatalog, AgentDefaults, AgentLoader, AgentRuntimeBuilder}; +use reloaded_code_core::context::{ToolContext, ToolPrompt}; +use reloaded_code_core::{ + CredentialResolver, CustomTool, CustomToolDefinition, CustomToolFuture, ToolBuildContext, + ToolCatalogEntry, ToolCatalogKind, ToolFactory, ToolOutput, ToolResult, ToolRunContext, + default_tools, resolve_workspace_root, +}; +use reloaded_code_models_dev::ModelsDevCatalog; +use reloaded_code_serdesai::AgentBuildContext; +use serde_json::json; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +const AGENT_NAME: &str = "custom-tool-demo"; +const MODEL_ID: &str = "synthetic/hf:zai-org/GLM-4.7-Flash"; +const API_KEY_NAME: &str = "SYNTHETIC_API_KEY"; +const API_KEY_VALUE: &str = ""; // <-- Set your API key here +const PROJECT_INFO_TOOL: &str = "project_info"; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let agents_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("examples") + .join("agents") + .join("custom-tool"); + + let mut credentials = CredentialResolver::without_env(); + if !API_KEY_VALUE.is_empty() { + credentials.set_override(API_KEY_NAME, API_KEY_VALUE); + } + + // Load model catalog from models.dev (online-first with local cache fallback). + let load_result = ModelsDevCatalog::load().await?; + println!( + "Loaded model catalog from models.dev (source: {:?})", + load_result.source + ); + + let mut catalog = AgentCatalog::new(); + AgentLoader::new().add_file(&mut catalog, agents_dir.join("custom-tool-demo.md"))?; + + let workspace_root = resolve_workspace_root()?; + let mut tools = default_tools(); + tools.push(ToolCatalogEntry::new( + PROJECT_INFO_TOOL, + ToolCatalogKind::Custom, + )); + + let runtime = AgentRuntimeBuilder::new() + .catalog(catalog) + .defaults(AgentDefaults::with_model(MODEL_ID)) + .tools(tools) + .custom_tool(ProjectInfoFactory) + .build()?; + + let build_context = AgentBuildContext::new( + Arc::new(runtime), + Arc::new(load_result.catalog), + Arc::new(credentials), + Arc::from(workspace_root.as_path()), + ); + + println!("Building `{AGENT_NAME}` with portable custom tool `{PROJECT_INFO_TOOL}`."); + let agent = build_context.build(AGENT_NAME)?; + println!("Built `{AGENT_NAME}` with {} tools.", agent.tools().len()); + + let prompt = "Call project_info with include_examples=true, then summarize what it says in three bullets."; + let response = agent.run(prompt, ()).await?; + println!("{}", response.output()); + + Ok(()) +} + +/// Factory registered with the framework-agnostic runtime. +struct ProjectInfoFactory; + +impl ToolContext for ProjectInfoFactory { + fn name(&self) -> &'static str { + PROJECT_INFO_TOOL + } + + fn context(&self) -> ToolPrompt { + ToolPrompt::Static( + "Use project_info to inspect demo metadata exposed by the host application.", + ) + } +} + +impl ToolFactory for ProjectInfoFactory { + fn create(&self, ctx: &ToolBuildContext) -> ToolResult> { + Ok(Arc::new(ProjectInfoTool { + workspace_root: ctx.workspace_root().to_path_buf(), + manifest_dir: PathBuf::from(env!("CARGO_MANIFEST_DIR")), + })) + } +} + +/// The portable custom tool implementation. +/// +/// This type depends only on `reloaded-code-core`, not SerdesAI. Other framework +/// adapters can wrap the same `CustomTool` object in their native tool trait. +struct ProjectInfoTool { + workspace_root: PathBuf, + manifest_dir: PathBuf, +} + +impl ToolContext for ProjectInfoTool { + fn name(&self) -> &'static str { + PROJECT_INFO_TOOL + } + + fn context(&self) -> ToolPrompt { + ToolPrompt::Static( + "Use project_info to inspect demo metadata exposed by the host application.", + ) + } +} + +impl CustomTool for ProjectInfoTool { + fn definition(&self) -> CustomToolDefinition { + CustomToolDefinition::new( + PROJECT_INFO_TOOL, + "Return host-provided metadata about this repository and custom tool demo.", + ) + .with_parameters(json!({ + "type": "object", + "properties": { + "include_examples": { + "type": "boolean", + "description": "Include the names of SerdesAI example files." + } + }, + "additionalProperties": false + })) + } + + fn call<'a>( + &'a self, + ctx: ToolRunContext<'a>, + args: serde_json::Value, + ) -> CustomToolFuture<'a> { + Box::pin(async move { + let include_examples = args + .get("include_examples") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + + let mut lines = vec![ + format!("workspace_root: {}", self.workspace_root.display()), + format!("serdesai_manifest_dir: {}", self.manifest_dir.display()), + format!( + "called_by_model: {}", + ctx.model_name().unwrap_or("") + ), + format!("run_id_present: {}", ctx.run_id().is_some()), + format!("tool_call_id_present: {}", ctx.tool_call_id().is_some()), + ]; + + if include_examples { + let examples = list_example_files(&self.manifest_dir)?; + lines.push(format!("serdesai_examples: {}", examples.join(", "))); + } + + Ok(ToolOutput::new(lines.join("\n"))) + }) + } +} + +fn list_example_files(manifest_dir: &Path) -> ToolResult> { + let examples_dir = manifest_dir.join("examples"); + let mut names = Vec::new(); + + for entry in std::fs::read_dir(examples_dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) == Some("rs") + && let Some(name) = path.file_name().and_then(|name| name.to_str()) + { + names.push(name.to_owned()); + } + } + + names.sort_unstable(); + Ok(names) +} diff --git a/src/reloaded-code-serdesai/src/agent_ext.rs b/src/reloaded-code-serdesai/src/agent_ext.rs index 22ee9a6..31597cc 100644 --- a/src/reloaded-code-serdesai/src/agent_ext.rs +++ b/src/reloaded-code-serdesai/src/agent_ext.rs @@ -44,7 +44,11 @@ impl> ToolExecutor for ToolAsEx // Convert agent::RunContext to tools::RunContext let tools_ctx = ToolsRunContext::from_arc(ctx.deps.clone(), &ctx.model_name) .with_run_id(&ctx.run_id) - .with_model_settings(ctx.model_settings.clone()); + .with_model_settings(ctx.model_settings.clone()) + .with_tool_context( + ctx.tool_name.as_deref().unwrap_or_default(), + ctx.tool_call_id.clone(), + ); self.0.call(&tools_ctx, args).await } @@ -64,7 +68,11 @@ impl ToolExecutor for DynToolAsExecutor ) -> Result { let tools_ctx = ToolsRunContext::from_arc(ctx.deps.clone(), &ctx.model_name) .with_run_id(&ctx.run_id) - .with_model_settings(ctx.model_settings.clone()); + .with_model_settings(ctx.model_settings.clone()) + .with_tool_context( + ctx.tool_name.as_deref().unwrap_or_default(), + ctx.tool_call_id.clone(), + ); self.0.call(&tools_ctx, args).await } diff --git a/src/reloaded-code-serdesai/src/agent_runtime/build.rs b/src/reloaded-code-serdesai/src/agent_runtime/build.rs index 24366fa..c71d544 100644 --- a/src/reloaded-code-serdesai/src/agent_runtime/build.rs +++ b/src/reloaded-code-serdesai/src/agent_runtime/build.rs @@ -8,6 +8,7 @@ use super::model::resolve_model; use super::provider_bridge::build_serdes_model; use crate::agent_ext::{AgentBuilderExt, ToolResultExt}; use crate::task::{TaskHandle, TaskTool}; +use crate::tools::CustomToolAdapter; use crate::{ BashTool, EditTool, GlobTool, GrepTool, ReadTool, SystemPromptBuilder, WebFetchTool, WriteTool, create_todo_tools, @@ -58,11 +59,22 @@ pub enum AgentBuildError { /// The tool name with no registered factory. name: Box, }, - /// A custom tool factory returned a value that cannot be downcast to the expected type. - #[error("custom tool `{name}` factory returned incompatible type")] - CustomToolDowncastFailed { - /// The tool name whose factory returned the wrong type. + /// A custom tool factory failed while creating the tool. + #[error("failed to create custom tool `{name}`: {source}")] + CustomToolCreateFailed { + /// The tool name whose factory failed. name: Box, + /// The underlying Core tool error. + #[source] + source: ToolError, + }, + /// A custom tool's own name or definition name does not match the catalog entry. + #[error("custom tool catalog entry `{catalog_name}` produced tool named `{actual_name}`")] + CustomToolNameMismatch { + /// The catalog entry name. + catalog_name: Box, + /// The mismatched name returned by the custom tool. + actual_name: Box, }, /// The runtime contains a tool kind this adapter cannot materialise. #[error("tool `{name}` is not supported")] @@ -179,8 +191,8 @@ where /// Returns [`AgentBuildError::UnknownCustomTool`] when a [`ToolCatalogKind::Custom`] entry /// names a tool absent from the custom-tool registry. /// -/// Returns [`AgentBuildError::CustomToolDowncastFailed`] when the type-erased object -/// produced by a custom-tool factory cannot be downcast to `Box>`. +/// Returns [`AgentBuildError::CustomToolCreateFailed`] when a custom-tool +/// factory cannot create its portable tool object. /// /// Returns [`AgentBuildError::ToolSettingsValidation`] when resolver creation or settings /// building fails for any tool, including: @@ -303,31 +315,40 @@ where } })?; - // create() returns type-erased Box for the - // cross-crate boundary; downcast to the concrete Tool<()> - // that the serdesai builder expects. - let boxed = factory.create(&build_context); - - // Downcast yields Box>> because create() - // wrapped Box> as Box. - let double_boxed: Box>> = - boxed - .downcast() - .map_err(|_| AgentBuildError::CustomToolDowncastFailed { - name: entry.name.into(), - })?; - - // Use ToolContext (which ToolFactory extends) to get - // name and prompt guidance consistently with built-in tools. - let tool_prompt = factory.context(); + let tool = factory.create(&build_context).map_err(|source| { + AgentBuildError::CustomToolCreateFailed { + name: entry.name.into(), + source, + } + })?; + + if tool.name() != entry.name { + return Err(AgentBuildError::CustomToolNameMismatch { + catalog_name: entry.name.into(), + actual_name: tool.name().into(), + }); + } + + let definition = tool.definition(); + if definition.name != entry.name { + return Err(AgentBuildError::CustomToolNameMismatch { + catalog_name: entry.name.into(), + actual_name: definition.name.into(), + }); + } + + // Use ToolContext to get name and prompt guidance consistently + // with built-in tools. ToolPrompt::Static("") means no guidance. + let tool_prompt = tool.context(); // ToolPrompt::Static("") means no guidance (equivalent to // the old prompt() returning None). if !matches!(tool_prompt, ToolPrompt::Static("")) { - prompt_builder.track_entry(factory.name(), tool_prompt); + prompt_builder.track_entry(tool.name(), tool_prompt); } - // Unwrap the double box to get Box>. - let tool = *double_boxed; - builder = builder.tool_dyn(tool.definition(), tool); + + let serdes_definition = crate::convert::custom_definition_to_serdes(definition); + builder = + builder.tool_dyn(serdes_definition, Box::new(CustomToolAdapter::new(tool))); } _ => { return Err(AgentBuildError::UnsupportedToolKind { @@ -401,12 +422,14 @@ mod tests { bash as bash_meta, glob as glob_meta, grep as grep_meta, read as read_meta, }; use reloaded_code_core::{ - CredentialResolver, SharedToolRegistry, ToolBuildContext, ToolCatalogEntry, - ToolCatalogKind, ToolFactory, + CredentialResolver, CustomTool, CustomToolDefinition, CustomToolFuture, SharedToolRegistry, + ToolBuildContext, ToolCatalogEntry, ToolCatalogKind, ToolError, ToolFactory, ToolOutput, + ToolResult, ToolRunContext, }; use serdes_ai::AgentBuilder; use serdes_ai_models::MockModel; use std::collections::HashSet; + use std::sync::Arc; type TestResult = Result<(), ExpandError>; @@ -778,9 +801,7 @@ mod tests { } #[test] - fn build_returns_error_on_custom_tool_downcast_failure() -> TestResult { - use std::any::Any; - + fn build_returns_error_on_custom_tool_create_failure() -> TestResult { struct BadFactory; impl ToolContext for BadFactory { fn name(&self) -> &'static str { @@ -791,17 +812,65 @@ mod tests { } } impl ToolFactory for BadFactory { - fn create(&self, _ctx: &ToolBuildContext) -> Box { - // Returns wrong type - not Box> - Box::new(42_usize) + fn create(&self, _ctx: &ToolBuildContext) -> ToolResult> { + Err(ToolError::validation("bad custom tool setup")) } } let runtime = custom_tool_runtime("bad_agent", "bad_tool", BadFactory)?; let result = attach_test_agent(&runtime, "bad_agent"); assert!( - matches!(&result, Err(AgentBuildError::CustomToolDowncastFailed { name } ) if &**name == "bad_tool"), - "expected CustomToolDowncastFailed for bad_tool, got a different result" + matches!(&result, Err(AgentBuildError::CustomToolCreateFailed { name, .. } ) if &**name == "bad_tool"), + "expected CustomToolCreateFailed for bad_tool, got a different result" + ); + Ok(()) + } + + #[test] + fn build_returns_error_on_custom_tool_name_mismatch() -> TestResult { + struct MismatchFactory; + impl ToolContext for MismatchFactory { + fn name(&self) -> &'static str { + "catalog_tool" + } + fn context(&self) -> ToolPrompt { + ToolPrompt::Static("Mismatch tool guidance.") + } + } + impl ToolFactory for MismatchFactory { + fn create(&self, _ctx: &ToolBuildContext) -> ToolResult> { + Ok(Arc::new(MismatchTool)) + } + } + + struct MismatchTool; + impl ToolContext for MismatchTool { + fn name(&self) -> &'static str { + "definition_tool" + } + fn context(&self) -> ToolPrompt { + ToolPrompt::Static("Mismatch tool guidance.") + } + } + impl CustomTool for MismatchTool { + fn definition(&self) -> CustomToolDefinition { + CustomToolDefinition::new("definition_tool", "wrong name") + } + + fn call<'a>( + &'a self, + _ctx: ToolRunContext<'a>, + _args: serde_json::Value, + ) -> CustomToolFuture<'a> { + Box::pin(async { Ok(ToolOutput::new("ok")) }) + } + } + + let runtime = custom_tool_runtime("bad_agent", "catalog_tool", MismatchFactory)?; + let result = attach_test_agent(&runtime, "bad_agent"); + assert!( + matches!(&result, Err(AgentBuildError::CustomToolNameMismatch { catalog_name, actual_name }) if &**catalog_name == "catalog_tool" && &**actual_name == "definition_tool"), + "expected CustomToolNameMismatch, got a different result" ); Ok(()) } diff --git a/src/reloaded-code-serdesai/src/agent_runtime/task.rs b/src/reloaded-code-serdesai/src/agent_runtime/task.rs index 4c06749..1245767 100644 --- a/src/reloaded-code-serdesai/src/agent_runtime/task.rs +++ b/src/reloaded-code-serdesai/src/agent_runtime/task.rs @@ -174,8 +174,8 @@ where /// contains a tool kind this adapter cannot materialise. /// - Returns [`AgentBuildError::UnknownCustomTool`] when a custom tool /// entry names a tool absent from the custom-tool registry. - /// - Returns [`AgentBuildError::CustomToolDowncastFailed`] when a - /// custom-tool factory produces an object that cannot be downcast. + /// - Returns [`AgentBuildError::CustomToolCreateFailed`] when a + /// custom-tool factory cannot create its portable tool object. #[inline] pub fn build(&self, name: &str) -> Result, AgentBuildError> { build_agent(Arc::clone(&self.context), name, 0) @@ -306,8 +306,8 @@ where /// contains a tool kind this adapter cannot materialise. /// - Returns [`AgentBuildError::UnknownCustomTool`] when a custom tool entry /// names a tool absent from the custom-tool registry. -/// - Returns [`AgentBuildError::CustomToolDowncastFailed`] when a custom-tool -/// factory produces an object that cannot be downcast. +/// - Returns [`AgentBuildError::CustomToolCreateFailed`] when a custom-tool +/// factory cannot create its portable tool object. pub(crate) fn build_agent( context: Arc>, name: &str, diff --git a/src/reloaded-code-serdesai/src/agent_runtime/test_stubs.rs b/src/reloaded-code-serdesai/src/agent_runtime/test_stubs.rs index 55c3023..982d7cc 100644 --- a/src/reloaded-code-serdesai/src/agent_runtime/test_stubs.rs +++ b/src/reloaded-code-serdesai/src/agent_runtime/test_stubs.rs @@ -1,34 +1,48 @@ //! Shared test stubs for SerdesAI custom tool tests. -//! -//! These combine a [`serdes_ai::Tool<()>`] implementation with a -//! [`ToolFactory`] so tests can exercise the full agent-build pipeline -//! including tool attachment and prompt guidance injection. -use async_trait::async_trait; use reloaded_code_core::context::{ToolContext, ToolPrompt}; -use reloaded_code_core::{ToolBuildContext, ToolFactory}; -use serdes_ai::tools::{RunContext, ToolDefinition, ToolReturn}; -use std::any::Any; +use reloaded_code_core::{ + CustomTool, CustomToolDefinition, CustomToolFuture, ToolBuildContext, ToolFactory, ToolOutput, + ToolResult, ToolRunContext, +}; +use std::sync::Arc; -/// A minimal `serdes_ai::Tool<()>` that returns a configurable text response. +/// A minimal portable custom tool that returns a configurable text response. struct SerdesTestTool { name: &'static str, + prompt: &'static str, response: &'static str, } -#[async_trait] -impl serdes_ai::Tool<()> for SerdesTestTool { - fn definition(&self) -> ToolDefinition { - ToolDefinition::new(self.name, self.name) +impl ToolContext for SerdesTestTool { + #[inline] + fn name(&self) -> &'static str { + self.name } - async fn call(&self, _ctx: &RunContext<()>, _args: serde_json::Value) -> serdes_ai::ToolResult { - Ok(ToolReturn::text(self.response)) + #[inline] + fn context(&self) -> ToolPrompt { + ToolPrompt::Static(self.prompt) + } +} + +impl CustomTool for SerdesTestTool { + #[inline] + fn definition(&self) -> CustomToolDefinition { + CustomToolDefinition::new(self.name, self.name) + } + + #[inline] + fn call<'a>( + &'a self, + _ctx: ToolRunContext<'a>, + _args: serde_json::Value, + ) -> CustomToolFuture<'a> { + Box::pin(async move { Ok(ToolOutput::new(self.response)) }) } } -/// A `ToolFactory` that creates a [`SerdesTestTool`] and returns it as a -/// double-boxed `Box`. +/// A `ToolFactory` that creates a portable [`SerdesTestTool`]. /// /// `name` and `prompt` are surfaced via `ToolContext` for system-prompt /// guidance injection. `response` is returned by the tool's `call()`. @@ -69,11 +83,11 @@ impl ToolContext for SerdesTestFactory { impl ToolFactory for SerdesTestFactory { #[inline] - fn create(&self, _ctx: &ToolBuildContext) -> Box { - let tool: Box> = Box::new(SerdesTestTool { + fn create(&self, _ctx: &ToolBuildContext) -> ToolResult> { + Ok(Arc::new(SerdesTestTool { name: self.name, + prompt: self.prompt, response: self.response, - }); - Box::new(tool) + })) } } diff --git a/src/reloaded-code-serdesai/src/convert.rs b/src/reloaded-code-serdesai/src/convert.rs index 9107748..ee1367b 100644 --- a/src/reloaded-code-serdesai/src/convert.rs +++ b/src/reloaded-code-serdesai/src/convert.rs @@ -5,9 +5,11 @@ //! //! [`reloaded_code_core`]: reloaded_code_core -use reloaded_code_core::{ToolError as CoreError, ToolOutput, ToolResult as CoreResult}; +use reloaded_code_core::{ + CustomToolDefinition, ToolError as CoreError, ToolOutput, ToolResult as CoreResult, +}; use serde_json::json; -use serdes_ai::tools::{ToolError as SerdesError, ToolReturn}; +use serdes_ai::tools::{ToolDefinition, ToolError as SerdesError, ToolReturn}; /// Convert [`ToolOutput`] to [`ToolReturn`] (serdesAI). /// @@ -109,6 +111,38 @@ fn field_for_out_of_bounds(msg: &str) -> Option { } } +/// Convert a portable [`CustomToolDefinition`] to a SerdesAI [`ToolDefinition`]. +/// +/// Fields map 1:1. The SerdesAI `outer_typed_dict_key` field is always `None` +/// because portable definitions do not carry framework-specific metadata. +/// +/// # Example +/// +/// ``` +/// use reloaded_code_serdesai::convert::custom_definition_to_serdes; +/// use reloaded_code_core::CustomToolDefinition; +/// use serde_json::json; +/// +/// let def = CustomToolDefinition::new("my_tool", "Does things") +/// .with_parameters(json!({"type": "object", "properties": {}})) +/// .with_strict(true); +/// +/// let serdes_def = custom_definition_to_serdes(def); +/// assert_eq!(serdes_def.name, "my_tool"); +/// assert_eq!(serdes_def.strict, Some(true)); +/// assert!(serdes_def.outer_typed_dict_key.is_none()); +/// ``` +#[inline] +pub fn custom_definition_to_serdes(definition: CustomToolDefinition) -> ToolDefinition { + ToolDefinition { + name: definition.name, + description: definition.description, + parameters_json_schema: definition.parameters_json_schema, + strict: definition.strict, + outer_typed_dict_key: None, + } +} + #[cfg(test)] mod tests { use super::*; @@ -244,4 +278,30 @@ mod tests { _ => unreachable!(), } } + + #[test] + fn custom_definition_to_serdes_maps_all_fields() { + use reloaded_code_core::CustomToolDefinition; + + let def = CustomToolDefinition::new("my_tool", "Does things") + .with_parameters(json!({"type": "object", "properties": {"q": {"type": "string"}}})) + .with_strict(true); + + let serdes_def = super::custom_definition_to_serdes(def); + assert_eq!(serdes_def.name, "my_tool"); + assert_eq!(serdes_def.description, "Does things"); + assert_eq!(serdes_def.strict, Some(true)); + assert!(serdes_def.outer_typed_dict_key.is_none()); + assert!(serdes_def.parameters_json_schema["properties"]["q"].is_object()); + } + + #[test] + fn custom_definition_to_serdes_defaults_strict_to_none() { + use reloaded_code_core::CustomToolDefinition; + + let def = CustomToolDefinition::new("basic", "no strict"); + let serdes_def = super::custom_definition_to_serdes(def); + assert_eq!(serdes_def.strict, None); + assert_eq!(serdes_def.outer_typed_dict_key, None); + } } diff --git a/src/reloaded-code-serdesai/src/lib.rs b/src/reloaded-code-serdesai/src/lib.rs index 95169b8..a93096f 100644 --- a/src/reloaded-code-serdesai/src/lib.rs +++ b/src/reloaded-code-serdesai/src/lib.rs @@ -31,8 +31,8 @@ pub use reloaded_code_core::path::{ // Re-export tools from the tools module pub use tools::{ - BashTool, EditTool, GlobTool, GrepTool, ReadTool, TodoReadTool, TodoWriteTool, WebFetchTool, - WriteTool, create_todo_tools, + BashTool, CustomToolAdapter, EditTool, GlobTool, GrepTool, ReadTool, TodoReadTool, + TodoWriteTool, WebFetchTool, WriteTool, create_todo_tools, }; // Re-export core operation types used by tools diff --git a/src/reloaded-code-serdesai/src/tools/custom.rs b/src/reloaded-code-serdesai/src/tools/custom.rs new file mode 100644 index 0000000..67143d0 --- /dev/null +++ b/src/reloaded-code-serdesai/src/tools/custom.rs @@ -0,0 +1,265 @@ +//! SerdesAI adapter for portable core custom tools. +//! +//! Wraps an [`Arc`] so it can be used directly with SerdesAI's +//! [`AgentBuilder`](serdes_ai::AgentBuilder) via +//! [`AgentBuilderExt`](crate::agent_ext::AgentBuilderExt). +//! +//! The adapter is also used internally by the agent-runtime build layer, but +//! it lives here so non-agent users can attach portable custom tools to a plain +//! SerdesAI agent without going through [`AgentRuntimeBuilder`]. +//! +//! [`AgentRuntimeBuilder`]: reloaded_code_agents::AgentRuntimeBuilder +//! [`AgentBuilderExt`]: crate::agent_ext::AgentBuilderExt +//! +//! # Example +//! +//! ```no_run +//! use reloaded_code_core::{ +//! CustomTool, CustomToolDefinition, CustomToolFuture, ToolOutput, ToolResult, +//! ToolRunContext, context::{ToolContext, ToolPrompt}, +//! }; +//! use reloaded_code_serdesai::tools::CustomToolAdapter; +//! use reloaded_code_serdesai::agent_ext::AgentBuilderExt; +//! use reloaded_code_serdesai::SystemPromptBuilder; +//! use serdes_ai::prelude::*; +//! use serde_json::json; +//! use std::sync::Arc; +//! +//! // 1. Implement the portable custom tool (depends only on reloaded-code-core) +//! struct EchoTool; +//! +//! impl ToolContext for EchoTool { +//! fn name(&self) -> &'static str { "echo" } +//! fn context(&self) -> ToolPrompt { +//! ToolPrompt::Static("Use echo to repeat a message.") +//! } +//! } +//! +//! impl CustomTool for EchoTool { +//! fn definition(&self) -> CustomToolDefinition { +//! CustomToolDefinition::new("echo", "Echo a message back") +//! .with_parameters(json!({ +//! "type": "object", +//! "properties": { +//! "message": { "type": "string", "description": "Message to echo" } +//! }, +//! "required": ["message"] +//! })) +//! } +//! +//! fn call<'a>(&'a self, _ctx: ToolRunContext<'a>, args: serde_json::Value) -> CustomToolFuture<'a> { +//! Box::pin(async move { +//! let msg = args["message"].as_str().unwrap_or_default(); +//! Ok(ToolOutput::new(msg)) +//! }) +//! } +//! } +//! +//! // 2. Wrap with CustomToolAdapter and attach to a SerdesAI agent +//! let mut pb = SystemPromptBuilder::new(); +//! let agent = AgentBuilder::<(), String>::from_model("openai:gpt-5.4")? +//! .tool(pb.track(CustomToolAdapter::new(Arc::new(EchoTool)))) +//! .system_prompt(pb.build()) +//! .build(); +//! # Ok::<(), Box>(()) +//! ``` + +use async_trait::async_trait; +use reloaded_code_core::context::ToolContext; +use reloaded_code_core::{CustomTool, ToolRunContext}; +use serdes_ai::tools::{RunContext, Tool, ToolDefinition}; +use std::sync::Arc; + +/// SerdesAI adapter for portable core custom tools. +/// +/// Wraps a [`CustomTool`] trait object and implements the SerdesAI [`Tool`] +/// trait so it can be registered via +/// [`AgentBuilderExt::tool`](crate::agent_ext::AgentBuilderExt::tool) or the +/// dynamic [`AgentBuilderExt::tool_dyn`](crate::agent_ext::AgentBuilderExt::tool_dyn) +/// method. +/// +/// See the [module-level documentation](crate::tools) for related tool types. +/// +/// [`AgentBuilderExt`]: crate::agent_ext::AgentBuilderExt +pub struct CustomToolAdapter { + inner: Arc, +} + +impl CustomToolAdapter { + /// Creates a new adapter wrapping the given portable custom tool. + #[inline] + pub fn new(inner: Arc) -> Self { + Self { inner } + } +} + +impl ToolContext for CustomToolAdapter { + #[inline] + fn name(&self) -> &'static str { + self.inner.name() + } + + #[inline] + fn context(&self) -> reloaded_code_core::context::ToolPrompt { + self.inner.context() + } +} + +#[async_trait] +impl Tool for CustomToolAdapter { + fn definition(&self) -> ToolDefinition { + crate::convert::custom_definition_to_serdes(self.inner.definition()) + } + + async fn call(&self, ctx: &RunContext, args: serde_json::Value) -> serdes_ai::ToolResult { + // Translate SerdesAI's RunContext into the framework-neutral ToolRunContext + // expected by the inner portable custom tool. + let mut run_ctx = ToolRunContext::new() + .with_model_name(ctx.model_name.as_str()) + .with_run_id(ctx.run_id.as_str()); + if let Some(tool_call_id) = ctx.tool_call_id.as_deref() { + run_ctx = run_ctx.with_tool_call_id(tool_call_id); + } + + crate::convert::to_serdes_result(self.inner.name(), self.inner.call(run_ctx, args).await) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use reloaded_code_core::context::{ToolContext, ToolPrompt}; + use reloaded_code_core::{CustomToolDefinition, CustomToolFuture, ToolOutput}; + use serde_json::json; + use serdes_ai::tools::RunContext as ToolsRunContext; + + /// Minimal portable tool for testing the adapter. + struct EchoTool; + + impl ToolContext for EchoTool { + fn name(&self) -> &'static str { + "test_echo" + } + fn context(&self) -> ToolPrompt { + ToolPrompt::Static("Echo tool for tests.") + } + } + + impl CustomTool for EchoTool { + fn definition(&self) -> CustomToolDefinition { + CustomToolDefinition::new("test_echo", "Echoes input") + .with_parameters(json!({ + "type": "object", + "properties": { + "msg": { "type": "string" } + } + })) + .with_strict(true) + } + + fn call<'a>( + &'a self, + ctx: ToolRunContext<'a>, + args: serde_json::Value, + ) -> CustomToolFuture<'a> { + Box::pin(async move { + let msg = args["msg"].as_str().unwrap_or_default(); + let model = ctx.model_name().unwrap_or("unknown"); + Ok(ToolOutput::new(format!("{model}:{msg}"))) + }) + } + } + + #[test] + fn adapter_exposes_name_and_context_from_inner() { + let adapter = CustomToolAdapter::new(Arc::new(EchoTool)); + assert_eq!(ToolContext::name(&adapter), "test_echo"); + assert!(matches!(adapter.context(), ToolPrompt::Static(_))); + } + + #[test] + fn adapter_converts_definition() { + let adapter = CustomToolAdapter::new(Arc::new(EchoTool)); + let def = >::definition(&adapter); + assert_eq!(def.name, "test_echo"); + assert_eq!(def.description, "Echoes input"); + assert_eq!(def.strict, Some(true)); + assert!(def.parameters_json_schema["properties"]["msg"].is_object()); + } + + #[tokio::test] + async fn adapter_forwards_call_and_context() { + let adapter = CustomToolAdapter::new(Arc::new(EchoTool)); + let ctx = ToolsRunContext::new((), "test-model"); + let args = json!({"msg": "hello"}); + + let result = adapter.call(&ctx, args).await; + let ret = result.expect("call should succeed"); + assert_eq!(ret.as_text(), Some("test-model:hello")); + } + + #[tokio::test] + async fn adapter_propagates_tool_call_id() { + struct CtxInspector; + impl ToolContext for CtxInspector { + fn name(&self) -> &'static str { + "inspector" + } + fn context(&self) -> ToolPrompt { + ToolPrompt::Static("") + } + } + impl CustomTool for CtxInspector { + fn definition(&self) -> CustomToolDefinition { + CustomToolDefinition::new("inspector", "inspector") + } + fn call<'a>( + &'a self, + ctx: ToolRunContext<'a>, + _args: serde_json::Value, + ) -> CustomToolFuture<'a> { + Box::pin(async move { + let id = ctx.tool_call_id().unwrap_or("none"); + Ok(ToolOutput::new(id.to_string())) + }) + } + } + + let adapter = CustomToolAdapter::new(Arc::new(CtxInspector)); + let ctx = ToolsRunContext::new((), "m") + .with_tool_context("inspector", Some("call-42".to_string())); + let result = adapter.call(&ctx, json!({})).await; + let ret = result.expect("should succeed"); + assert_eq!(ret.as_text(), Some("call-42")); + } + + #[tokio::test] + async fn adapter_maps_errors() { + struct FailTool; + impl ToolContext for FailTool { + fn name(&self) -> &'static str { + "fail" + } + fn context(&self) -> ToolPrompt { + ToolPrompt::Static("") + } + } + impl CustomTool for FailTool { + fn definition(&self) -> CustomToolDefinition { + CustomToolDefinition::new("fail", "always fails") + } + fn call<'a>( + &'a self, + _ctx: ToolRunContext<'a>, + _args: serde_json::Value, + ) -> CustomToolFuture<'a> { + Box::pin(async { Err(reloaded_code_core::ToolError::Execution("boom".into())) }) + } + } + + let adapter = CustomToolAdapter::new(Arc::new(FailTool)); + let ctx = ToolsRunContext::new((), "m"); + let result = adapter.call(&ctx, json!({})).await; + assert!(result.is_err()); + } +} diff --git a/src/reloaded-code-serdesai/src/tools/mod.rs b/src/reloaded-code-serdesai/src/tools/mod.rs index d5f4e25..d1a07f0 100644 --- a/src/reloaded-code-serdesai/src/tools/mod.rs +++ b/src/reloaded-code-serdesai/src/tools/mod.rs @@ -1,8 +1,11 @@ //! Tool adapters for the serdes_ai tool framework. //! -//! Each tool wraps a core operation and adapts it to the [`Tool`] trait +//! Built-in tools wrap core operations and adapt them to the [`Tool`] trait //! from `serdes_ai`. //! +//! [`CustomToolAdapter`] wraps a portable custom tool so user-defined tools +//! can be attached without writing a SerdesAI-specific wrapper. +//! //! File tools use a [`PathResolver`] to validate and resolve paths. The //! path mode (absolute or sandboxed) is detected automatically from the //! resolver type at construction time, which selects the correct schema @@ -33,6 +36,9 @@ //! - [`TodoWriteTool`] - write/replace the todo list //! - [`create_todo_tools`] - create a linked read/write pair with shared state //! +//! Adapter tools: +//! - [`CustomToolAdapter`] - wrap a portable [`reloaded_code_core::CustomTool`] +//! //! # Example //! //! ```no_run @@ -48,6 +54,7 @@ //! [`AllowedGlobResolver`]: reloaded_code_core::path::AllowedGlobResolver mod bash; +mod custom; mod edit; mod glob; mod grep; @@ -57,6 +64,7 @@ mod webfetch; mod write; pub use bash::BashTool; +pub use custom::CustomToolAdapter; pub use edit::EditTool; pub use glob::GlobTool; pub use grep::GrepTool;