diff --git a/README.MD b/README.MD
index 198ea86..5da37b7 100644
--- a/README.MD
+++ b/README.MD
@@ -25,13 +25,13 @@ A **CI pipeline**? A **custom product**?
**ReloadedCode** ships the same agent tools as a Rust library.
Shell sandboxing. Default-deny permissions. ~10 MiB footprint.
-| | OpenCode | ReloadedCode |
-| ------------ | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Language | TypeScript | Rust |
+| | OpenCode | ReloadedCode |
+| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Language | TypeScript | Rust |
| Memory | ~305 MiB | ~13 MiB |
-| Interface | TUI / Desktop / IDE | Library (headless) |
-| Agent format | Markdown + YAML | Similar format |
-| Embeddable | HTTP API | Rust crate |
+| Interface | TUI / Desktop / IDE | Library (headless) |
+| Agent format | Markdown + YAML | Similar format |
+| Embeddable | HTTP API | Rust crate |
## Features
@@ -114,13 +114,14 @@ async fn main() -> Result<(), Box> {
## Crate Map
-| Crate | Version | Description |
-| --------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------ |
-| [**reloaded-code-core**](./src/reloaded-code-core/) | 0.2 | Framework-agnostic tool implementations, path resolvers, permissions, system prompt builder |
-| [**reloaded-code-agents**](./src/reloaded-code-agents/) | 0.1 | agent markdown loader similar to [OpenCode](https://opencode.ai), typed catalog, runtime builder |
-| [**reloaded-code-serdesai**](./src/reloaded-code-serdesai/) | 0.2 | SerdesAI framework integration, tool adapters, 15 provider bridges, task delegation |
-| [**reloaded-code-bubblewrap**](./src/reloaded-code-bubblewrap/) | 0.1 | Linux bubblewrap sandbox profiles (Public Bot + Trusted Maintenance) |
-| [**reloaded-code-models-dev**](./src/reloaded-code-models-dev/) | 0.1 | models.dev catalog sync with ETag caching and offline fallback |
+| Crate | Version | Description |
+| ------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------ |
+| [**reloaded-code-core**](./src/reloaded-code-core/) | 0.2 | Framework-agnostic tool implementations, path resolvers, permissions, custom tool registry |
+| [**reloaded-code-agents**](./src/reloaded-code-agents/) | 0.1 | agent markdown loader similar to [OpenCode](https://opencode.ai), typed catalog, runtime builder |
+| [**reloaded-code-serdesai**](./src/reloaded-code-serdesai/) | 0.2 | SerdesAI framework integration, tool adapters, 15 provider bridges, task delegation |
+| [**reloaded-code-bubblewrap**](./src/reloaded-code-bubblewrap/) | 0.1 | Linux bubblewrap sandbox profiles (Public Bot + Trusted Maintenance) |
+| [**reloaded-code-models-dev**](./src/reloaded-code-models-dev/) | 0.1 | models.dev catalog sync with ETag caching and offline fallback |
+| [**reloaded-code-provider-config**](./src/reloaded-code-provider-config/) | 0.1 | Provider configuration loading and provider catalog overrides |
## Examples
diff --git a/docs/src/agents.md b/docs/src/agents.md
index 223ae4f..f1dcc02 100644
--- a/docs/src/agents.md
+++ b/docs/src/agents.md
@@ -92,6 +92,17 @@ Evaluation uses **last-match-wins**: the final matching rule takes effect.
For the full rule table and examples, see
[Tools > Permission rules](tools.md#permission-rules).
+#### Custom tool permissions
+
+Custom tools are referenced in the `permission` map by name, same as built-in tools.
+See [Custom tools](tools.md#custom-tools) for registration.
+
+```yaml
+permission:
+ web_search: allow # custom tool
+ database: deny # custom tool
+```
+
### Model specification
Format: `provider/model-id` or `synthetic/hf:huggingface-model-id`.
diff --git a/docs/src/architecture.md b/docs/src/architecture.md
index 73c8397..9d8f449 100644
--- a/docs/src/architecture.md
+++ b/docs/src/architecture.md
@@ -12,12 +12,14 @@ graph TD
serdesai["reloaded-code-serdesai
SerdesAI framework integration"]
bubblewrap["reloaded-code-bubblewrap
Linux sandbox profiles"]
modelsdev["reloaded-code-models-dev
models.dev catalog sync"]
+ providerconfig["reloaded-code-provider-config
Provider config loading"]
agents --> core
serdesai --> core
serdesai --> agents
serdesai -.->|optional| bubblewrap
modelsdev --> core
+ providerconfig --> core
classDef default fill:#1C1C1C,stroke:#fa7774,strokeWidth:2px,color:#fff
```
@@ -32,6 +34,8 @@ The foundation. Contains every tool implementation as a plain function
- **Path resolvers** - control which files tools can access
- **System prompt builder** - generates context-aware tool guidance
- **Permission engine** - last-match-wins rules with wildcard patterns
+- **Custom tool registry + catalog** - framework-agnostic `ToolFactory`,
+ `CustomToolRegistry`, and `ToolCatalogEntry` types
- **Credential resolver** - API key lookup with override support ([details](getting-started.md#credential-management))
- **Model catalog** - compact hash-table-based provider/model lookup
@@ -46,6 +50,7 @@ Loads agent definitions from markdown files with YAML frontmatter. Provides:
- **AgentLoader** - scans directories for `.md` agent files
- **AgentCatalog** - name-to-config lookup table
- **AgentRuntime** - bundles catalog + defaults + permissions + task settings
+- **AgentRuntimeBuilder** - accepts core tool catalogs and custom tool factories
The agent file format mirrors [OpenCode]'s schema - similar enough that many
files are drop-in compatible, but [not identical](migration.md). The most
@@ -82,6 +87,11 @@ Syncs the online [models.dev](https://models.dev) catalog into a compact
- Offline fallback when network is unavailable
- Cache load in ~0.3 ms
+### reloaded-code-provider-config
+
+Loads provider override configuration and turns it into provider catalog entries
+that can be merged with or replace the defaults from models.dev.
+
## Where your code plugs in
There are two integration paths:
diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md
index 0974d43..25bf8e4 100644
--- a/docs/src/getting-started.md
+++ b/docs/src/getting-started.md
@@ -155,6 +155,38 @@ a Rust project and an LLM API key (e.g. `OPENAI_API_KEY`).
[serdesai-agents](https://github.com/Reloaded-Project/ReloadedCode/blob/main/src/reloaded-code-serdesai/examples/serdesai-agents.rs)
(with agent files). See [Examples](examples.md) for the full list.
+## Custom tools
+
+Implement [`ToolContext`] and [`ToolFactory`], then register with the builder:
+
+```rust
+struct MyFactory;
+impl ToolContext for MyFactory {
+ fn name(&self) -> &'static str { "my_tool" }
+ fn context(&self) -> ToolPrompt {
+ ToolPrompt::Static("Guidance for using my_tool.")
+ }
+}
+impl ToolFactory for MyFactory {
+ fn create(&self, _ctx: &ToolBuildContext) -> Box {
+ todo!("return your tool")
+ }
+}
+
+let runtime = AgentRuntimeBuilder::new()
+ .custom_tool(MyFactory)
+ .tools(vec![
+ ToolCatalogEntry::new("my_tool", ToolCatalogKind::Custom),
+ ])
+ .build()?;
+```
+
+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"`) -
diff --git a/docs/src/guides/custom-framework.md b/docs/src/guides/custom-framework.md
index d864c4e..a7dc8d8 100644
--- a/docs/src/guides/custom-framework.md
+++ b/docs/src/guides/custom-framework.md
@@ -97,6 +97,10 @@ pb.track(read_tool);
// pb.track(other_tool);
// pb.track(another_tool);
+// For custom tools (e.g. tool factories, framework adapters) where you
+// have name + prompt but no instance, use track_entry():
+pb.track_entry("my_custom_tool", ToolPrompt::Static("Use my_custom_tool to do X."));
+
let system_prompt = pb.build();
```
@@ -143,22 +147,24 @@ let glob = AllowedGlobResolver::new(["/workspace/project"])?
## What you get from core
-| Component | What it provides |
-| ---------------------------------------------- | ------------------------------------------- |
-| `read_file`, `write_file`, `edit_file` | File operations |
-| `glob_files`, `grep_search` | Search operations |
-| `execute_command`, `execute_command_with_mode` | Shell execution |
-| `fetch_url` | URL fetching |
-| `read_todos`, `write_todos` | Shared todo state |
-| `SystemPromptBuilder` | Context-aware system prompt generation |
-| `ToolContext` trait | Tool metadata interface for prompt building |
-| `PathResolver` trait | Path security boundary |
-| `AllowedPathResolver` | Directory-based sandbox |
+| Component | What it provides |
+| ---------------------------------------------- | -------------------------------------------------------- |
+| `read_file`, `write_file`, `edit_file` | File operations |
+| `glob_files`, `grep_search` | Search operations |
+| `execute_command`, `execute_command_with_mode` | Shell execution |
+| `fetch_url` | URL fetching |
+| `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 |
+| `ToolCatalogEntry` / `ToolCatalogKind` | Standard/custom tool catalog for adapters |
+| `PathResolver` trait | Path security boundary |
+| `AllowedPathResolver` | Directory-based sandbox |
| `AllowedGlobResolver` | Glob-based sandbox (last matching rule takes precedence) |
-| `Ruleset` / `Rule` | Permission evaluation engine |
-| `CredentialResolver` | API key lookup with overrides |
-| `ModelCatalog` | Compact provider/model hash table |
-| `ToolError` | Unified error type for all tools |
+| `Ruleset` / `Rule` | Permission evaluation engine |
+| `CredentialResolver` | API key lookup with overrides |
+| `ModelCatalog` | Compact provider/model hash table |
+| `ToolError` | Unified error type for all tools |
For the full API reference, see [docs.rs/reloaded-code-core](https://docs.rs/reloaded-code-core).
diff --git a/docs/src/tools.md b/docs/src/tools.md
index bb6fba3..71da74f 100644
--- a/docs/src/tools.md
+++ b/docs/src/tools.md
@@ -118,6 +118,58 @@ permission:
| [**todoread**](#todoread-todowrite) | `read_todos` | Read shared todo list state |
| [**todowrite**](#todoread-todowrite) | `write_todos` | Update shared todo list state |
| [**task**](#task) | `TaskInput`/`TaskOutput` | Delegate work to a named sub-agent |
+| [**custom**](#custom-tools) | `ToolFactory` | User-defined tool registered by the embedder |
+
+### Custom tools
+
+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,
+};
+use reloaded_code_core::context::ToolPrompt;
+use std::any::Any;
+
+struct WebSearchFactory;
+
+// Name + prompt guidance.
+impl ToolContext for WebSearchFactory {
+ fn name(&self) -> &'static str { "web_search" }
+ fn context(&self) -> ToolPrompt {
+ ToolPrompt::Static("Use web_search to find information online.")
+ }
+}
+
+// Build framework-specific tool instance.
+impl ToolFactory for WebSearchFactory {
+ fn create(&self, _ctx: &ToolBuildContext) -> Box {
+ // SerdesAI: return Box::new(Box>).
+ todo!("return your tool")
+ }
+}
+
+let runtime = AgentRuntimeBuilder::new()
+ // Register factory.
+ .custom_tool(WebSearchFactory)
+ // Enable tool in catalog.
+ .tools(vec![
+ ToolCatalogEntry::new("web_search", ToolCatalogKind::Custom),
+ ])
+ .build()?;
+```
+
+Rules:
+
+- Factory 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`.
+
+See [reloaded-code-core API docs](https://docs.rs/reloaded-code-core/latest)
+for full API details.
### read
@@ -422,6 +474,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
-[Agents]: agents.md
-[agent files]: agents.md
+[reloaded-code-serdesai]: https://docs.rs/reloaded-code-serdesai
\ No newline at end of file
diff --git a/src/reloaded-code-agents/ARCHITECTURE.md b/src/reloaded-code-agents/ARCHITECTURE.md
index beb3d4f..1b9bbfe 100644
--- a/src/reloaded-code-agents/ARCHITECTURE.md
+++ b/src/reloaded-code-agents/ARCHITECTURE.md
@@ -175,7 +175,8 @@ Fallback settings used when an individual agent doesn't specify them:
### Tool Catalog
-`default_tools()` returns 10 entries:
+`reloaded-code-core::default_tools()` returns 10 entries that
+`AgentRuntimeBuilder` uses by default:
| Kind | Tool name |
| --------- | ----------- |
@@ -340,8 +341,7 @@ reloaded-code-agents
│ ├── state.rs AgentRuntime, AgentDefaults
│ ├── builder.rs AgentRuntimeBuilder
│ ├── model.rs resolve_model_with_catalog(), ResolvedModel, ModelResolutionError
-│ ├── task.rs callable_targets(), summarize_callable_targets(), allowed_tools()
-│ └── tool_catalog.rs ToolCatalogEntry, ToolCatalogKind, default_tools()
+│ └── task.rs callable_targets(), summarize_callable_targets(), allowed_tools()
└── benches/
└── parser.rs Criterion benchmarks for frontmatter parsing
```
diff --git a/src/reloaded-code-agents/README.md b/src/reloaded-code-agents/README.md
index 468b47a..b856ea7 100644
--- a/src/reloaded-code-agents/README.md
+++ b/src/reloaded-code-agents/README.md
@@ -272,6 +272,53 @@ let runtime = AgentRuntimeBuilder::new()
# Ok::<(), reloaded_code_agents::AgentLoadError>(())
```
+## Custom tools
+
+Embedders can register custom tools that integrate with the agent runtime,
+permission system, and system prompt builder. Custom tool primitives live in
+`reloaded-code-core`; implement [`ToolContext`] and [`ToolFactory`], then register
+the factory on the builder:
+
+```rust,no_run
+use reloaded_code_agents::AgentRuntimeBuilder;
+use reloaded_code_core::{
+ ToolBuildContext, ToolCatalogEntry, ToolCatalogKind, ToolContext, ToolFactory,
+};
+use reloaded_code_core::context::ToolPrompt;
+use std::any::Any;
+
+struct WebSearchFactory;
+
+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 {
+ todo!("return tool instance")
+ }
+}
+
+let tools = vec![
+ ToolCatalogEntry::new("web_search", ToolCatalogKind::Custom),
+];
+
+let runtime = AgentRuntimeBuilder::new()
+ .custom_tool(WebSearchFactory)
+ .tools(tools)
+ .build()?;
+
+# Ok::<(), reloaded_code_agents::AgentLoadError>(())
+```
+
+`create()` returns `Box`.
+
+If a `ToolCatalogKind::Custom` entry has no matching factory, build returns
+`AgentBuildError::UnknownCustomTool`.
+
## Differences from OpenCode
The agent file format mirrors [OpenCode]'s. Many files are drop-in
@@ -316,3 +363,6 @@ For the internal architecture, see [ARCHITECTURE.md](https://github.com/Reloaded
[OpenCode]: https://opencode.ai/
[Documentation]: https://reloaded-project.github.io/ReloadedCode/agents
[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/lib.rs b/src/reloaded-code-agents/src/lib.rs
index a1bf88a..9227bdb 100644
--- a/src/reloaded-code-agents/src/lib.rs
+++ b/src/reloaded-code-agents/src/lib.rs
@@ -16,9 +16,9 @@ pub use loader::AgentLoader;
pub use parser::AgentParseError;
pub use path::{build_resolver_for_tool, FileToolResolver};
pub use runtime::{
- callable_targets, default_tools, resolve_model_with_catalog, summarize_callable_targets,
- AgentDefaults, AgentRuntime, AgentRuntimeBuilder, ModelResolutionError, ResolvedModel,
- TaskSettings, TaskTargetSummary, ToolCatalogEntry, ToolCatalogKind,
+ callable_targets, resolve_model_with_catalog, summarize_callable_targets, AgentDefaults,
+ AgentRuntime, AgentRuntimeBuilder, ModelResolutionError, ResolvedModel, TaskSettings,
+ TaskTargetSummary,
};
pub use types::{
parse_model_parts, AgentConfig, AgentLoadError, AgentLoadResult, AgentMode, AgentToolSettings,
diff --git a/src/reloaded-code-agents/src/path/resolver.rs b/src/reloaded-code-agents/src/path/resolver.rs
index 90ee90e..52a87a4 100644
--- a/src/reloaded-code-agents/src/path/resolver.rs
+++ b/src/reloaded-code-agents/src/path/resolver.rs
@@ -182,7 +182,7 @@ fn build_glob_policy(
#[cfg(test)]
mod tests {
use super::*;
- use reloaded_code_core::ToolBuildContext;
+ use reloaded_code_core::tool_context::ToolBuildContext;
use soft_canonicalize::soft_canonicalize;
type TestResult = Result<(), ToolError>;
diff --git a/src/reloaded-code-agents/src/runtime/builder.rs b/src/reloaded-code-agents/src/runtime/builder.rs
index 0ec7128..6f2089c 100644
--- a/src/reloaded-code-agents/src/runtime/builder.rs
+++ b/src/reloaded-code-agents/src/runtime/builder.rs
@@ -1,18 +1,21 @@
//! Builds an [`AgentRuntime`] from your agents, defaults, and tools.
use super::state::{AgentDefaults, AgentRuntime};
-use super::tool_catalog::{default_tools, ToolCatalogEntry};
use crate::AgentCatalog;
use reloaded_code_core::permissions::ExpandError;
-use reloaded_code_core::TaskSettings;
+use reloaded_code_core::{
+ default_tools, CustomToolRegistry, SharedToolRegistry, TaskSettings, ToolCatalogEntry,
+ ToolFactory,
+};
/// Builds an [`AgentRuntime`] step by step.
-#[derive(Debug, Clone)]
+#[derive(Debug)]
pub struct AgentRuntimeBuilder {
catalog: AgentCatalog,
defaults: AgentDefaults,
task_settings: TaskSettings,
tools: Vec,
+ custom_tool_registry: CustomToolRegistry,
}
impl Default for AgentRuntimeBuilder {
@@ -31,6 +34,7 @@ impl AgentRuntimeBuilder {
defaults: AgentDefaults::default(),
task_settings: TaskSettings::default(),
tools: default_tools(),
+ custom_tool_registry: CustomToolRegistry::new(),
}
}
@@ -69,26 +73,46 @@ impl AgentRuntimeBuilder {
self
}
+ /// Registers a custom tool factory.
+ ///
+ /// The factory's name (via [`ToolContext::name`](reloaded_code_core::ToolContext::name))
+ /// must match the `name` field of the corresponding [`ToolCatalogEntry`] with kind
+ /// [`ToolCatalogKind::Custom`](reloaded_code_core::ToolCatalogKind::Custom).
+ pub fn custom_tool(mut self, factory: impl ToolFactory + 'static) -> Self {
+ self.custom_tool_registry.insert(factory);
+ self
+ }
+
/// Finishes building and returns the [`AgentRuntime`].
///
/// # Errors
/// - Returns [`ExpandError`] when any agent's permission configuration contains invalid patterns.
#[inline]
pub fn build(self) -> Result {
- AgentRuntime::from_parts(self.catalog, self.defaults, self.task_settings, self.tools)
+ AgentRuntime::from_parts(
+ self.catalog,
+ self.defaults,
+ self.task_settings,
+ self.tools,
+ SharedToolRegistry::from_registry(self.custom_tool_registry),
+ )
}
}
#[cfg(test)]
mod tests {
use super::AgentRuntimeBuilder;
- use crate::runtime::tool_catalog::{default_tools, ToolCatalogEntry, ToolCatalogKind};
use crate::runtime::AgentDefaults;
use crate::{AgentCatalog, AgentConfig, AgentMode, AgentToolSettings, PermissionRule};
use indexmap::IndexMap;
+ use reloaded_code_core::context::{ToolContext, ToolPrompt};
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::TaskSettings;
+ use reloaded_code_core::{
+ default_tools, TaskSettings, ToolBuildContext, ToolCatalogEntry, ToolCatalogKind,
+ ToolFactory,
+ };
+ use std::any::Any;
use std::sync::Arc;
type TestResult = Result<(), ExpandError>;
@@ -192,4 +216,46 @@ mod tests {
assert!(first.is_allowed(read_meta::NAME, "*"));
Ok(())
}
+
+ #[test]
+ fn builder_registers_custom_tool() -> TestResult {
+ struct TestFactory {
+ name: &'static str,
+ prompt: &'static str,
+ }
+
+ impl TestFactory {
+ fn new(name: &'static str, prompt: &'static str) -> Self {
+ Self { name, prompt }
+ }
+ }
+
+ impl ToolContext for TestFactory {
+ fn name(&self) -> &'static str {
+ self.name
+ }
+
+ fn context(&self) -> ToolPrompt {
+ ToolPrompt::Static(self.prompt)
+ }
+ }
+
+ impl ToolFactory for TestFactory {
+ fn create(&self, _ctx: &ToolBuildContext) -> Box {
+ Box::new(())
+ }
+ }
+
+ let runtime = AgentRuntimeBuilder::new()
+ .custom_tool(TestFactory::new("stub", "Stub tool guidance."))
+ .build()?;
+
+ let factory = runtime.custom_tool_registry().get("stub");
+ assert!(
+ factory.is_some(),
+ "custom tool factory should be registered"
+ );
+ assert_eq!(factory.unwrap().name(), "stub");
+ Ok(())
+ }
}
diff --git a/src/reloaded-code-agents/src/runtime/mod.rs b/src/reloaded-code-agents/src/runtime/mod.rs
index ab27d43..744098c 100644
--- a/src/reloaded-code-agents/src/runtime/mod.rs
+++ b/src/reloaded-code-agents/src/runtime/mod.rs
@@ -11,11 +11,6 @@
//! - [`AgentDefaults`] - Default model, temperature, and top-p when agents don't specify them
//! - [`TaskSettings`] - Shared Task delegation limits for all integrations using the runtime
//!
-//! Tools:
-//! - [`ToolCatalogEntry`] - One tool the runtime can provide to agents
-//! - [`ToolCatalogKind`] - Which tools are available
-//! - [`default_tools()`] - The standard tool set (read, write, edit, glob, grep, bash, webfetch, todo, task)
-//!
//! Task delegation:
//! - [`summarize_callable_targets()`] - Builds target summaries with names and descriptions
//! - [`callable_targets()`] - Returns the agents the active agent may delegate to
@@ -45,11 +40,9 @@ mod builder;
mod model;
mod state;
mod task;
-mod tool_catalog;
pub use builder::AgentRuntimeBuilder;
pub use model::{resolve_model_with_catalog, ModelResolutionError, ResolvedModel};
pub use reloaded_code_core::TaskSettings;
pub use state::{AgentDefaults, AgentRuntime};
pub use task::{callable_targets, summarize_callable_targets, TaskTargetSummary};
-pub use tool_catalog::{default_tools, ToolCatalogEntry, ToolCatalogKind};
diff --git a/src/reloaded-code-agents/src/runtime/state.rs b/src/reloaded-code-agents/src/runtime/state.rs
index 479120d..903a0bf 100644
--- a/src/reloaded-code-agents/src/runtime/state.rs
+++ b/src/reloaded-code-agents/src/runtime/state.rs
@@ -6,11 +6,10 @@
//! - [`AgentDefaults`] — Fallback settings when an agent doesn't specify them.
use super::task::{build_runtime_task_caches, TaskTargetSummary};
-use super::tool_catalog::ToolCatalogEntry;
use crate::{AgentCatalog, RulesetExt};
use ahash::AHashMap;
use reloaded_code_core::permissions::{ExpandError, Ruleset};
-use reloaded_code_core::TaskSettings;
+use reloaded_code_core::{SharedToolRegistry, TaskSettings, ToolCatalogEntry};
use std::sync::Arc;
/// Default settings used when an agent doesn't specify them.
@@ -43,6 +42,7 @@ pub struct AgentRuntime {
defaults: AgentDefaults,
task_settings: TaskSettings,
tools: Vec,
+ custom_tool_registry: SharedToolRegistry,
permission_rulesets: AHashMap>,
allowed_tools_by_caller: AHashMap>,
callable_target_summaries_by_caller: AHashMap>,
@@ -55,6 +55,7 @@ impl AgentRuntime {
defaults: AgentDefaults,
task_settings: TaskSettings,
tools: Vec,
+ custom_tool_registry: SharedToolRegistry,
) -> Result {
let permission_rulesets = catalog
.iter()
@@ -73,6 +74,7 @@ impl AgentRuntime {
defaults,
task_settings,
tools,
+ custom_tool_registry,
permission_rulesets,
allowed_tools_by_caller,
callable_target_summaries_by_caller,
@@ -103,6 +105,12 @@ impl AgentRuntime {
&self.tools
}
+ /// Returns the custom tool registry.
+ #[inline]
+ pub fn custom_tool_registry(&self) -> &SharedToolRegistry {
+ &self.custom_tool_registry
+ }
+
/// Returns the cached permission ruleset for the named caller.
///
/// The returned [`Arc`] is cheap to clone and reuses the ruleset built when
diff --git a/src/reloaded-code-agents/src/runtime/task.rs b/src/reloaded-code-agents/src/runtime/task.rs
index 47e985a..5fb4e1e 100644
--- a/src/reloaded-code-agents/src/runtime/task.rs
+++ b/src/reloaded-code-agents/src/runtime/task.rs
@@ -5,11 +5,11 @@
//! - [`callable_targets`] - Returns the agents an active agent may delegate to via Task.
//! - [`TaskTargetSummary`] - Stable Task UI metadata for a callable target.
-use super::tool_catalog::{ToolCatalogEntry, ToolCatalogKind};
use crate::{AgentCatalog, AgentConfig, AgentMode, RulesetExt};
use ahash::AHashMap;
use reloaded_code_core::permissions::{ExpandError, Ruleset};
use reloaded_code_core::tool_metadata::task as task_meta;
+use reloaded_code_core::{ToolCatalogEntry, ToolCatalogKind};
use std::sync::Arc;
/// Compact metadata used to describe one callable Task target.
diff --git a/src/reloaded-code-core/README.md b/src/reloaded-code-core/README.md
index f970512..d2a6911 100644
--- a/src/reloaded-code-core/README.md
+++ b/src/reloaded-code-core/README.md
@@ -3,7 +3,8 @@
[](https://crates.io/crates/reloaded-code-core) [](https://docs.rs/reloaded-code-core)
Framework-agnostic core tools for building coding agents - file operations,
-search, shell execution, sandboxing, permissions, and system prompt generation.
+search, shell execution, sandboxing, permissions, custom tool registries, and
+system prompt generation.
Headless, TUI, or anything in between. Production-grade implementations with minimal overhead.
@@ -22,6 +23,7 @@ Headless, TUI, or anything in between. Production-grade implementations with min
- [Context and wrapper mapping](#context-and-wrapper-mapping)
- [System prompt builder](#system-prompt-builder)
- [Typical wrapper integration (serdesAI)](#typical-wrapper-integration-serdesai)
+ - [Custom tool registry](#custom-tool-registry)
- [Permissions](#permissions)
- [Credentials](#credentials)
@@ -227,12 +229,18 @@ guidance in sync.
[`SystemPromptBuilder`] builds one prompt string for agent runtimes.
- [`track(&mut self, tool: T)`] records tool guidance and returns the tool unchanged.
+- [`track_entry(&mut self, name, prompt)`] records a raw context entry without wrapping a
+ tool. Use this when you have name and prompt but no tool instance to wrap via
+ [`SystemPromptBuilder::track`].
- [`working_directory(self, path)`] and [`allowed_paths(self, resolver)`] add environment metadata.
- [`add_context(self, name, context)`] appends supplemental sections (for example `GIT_WORKFLOW`).
- [`system_prompt(self, prompt)`] prepends custom instructions; [`build(self)`] renders the final prompt.
You usually build framework wrappers from these primitives (`ToolContext` + `SystemPromptBuilder`).
+If tools are selected from a catalog or created later, use [`ToolFactory`],
+[`ToolCatalogEntry`], and [`CustomToolRegistry`] from core.
+
### Typical wrapper integration (serdesAI)
For example with `reloaded-code-serdesai`, wrappers are built from these primitives.
@@ -263,6 +271,25 @@ The system prompt is auto-optimized: cross-tool references e.g.
`prefer X tool over Y for Z` are ommitted unless all tools are present.
Currently uses ~2000 tokens for full toolset, ~560 tokens for search-only.
+## Custom tool registry
+
+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.
+- [`ToolBuildContext`] - shared build-time info passed to every factory (workspace
+ root, permissions). Create once, reuse.
+- [`CustomToolRegistry`] - stores factories by tool name. Insert, lookup, iterate.
+- [`SharedToolRegistry`] - same as above, wrapped in `Arc`. Cheap to clone and
+ pass around runtime builders.
+- [`ToolCatalogEntry`] - pairs a model-facing tool name with its [`ToolCatalogKind`].
+- [`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.
+
## Permissions
[`permissions`] provides ordered allow/deny rules for tool access and delegation.
@@ -344,6 +371,7 @@ let key = resolver.resolve("OPENAI_API_KEY");
[`TaskSettings`]: crate::TaskSettings
[`SystemPromptBuilder`]: crate::SystemPromptBuilder
[`track(&mut self, tool: T)`]: crate::SystemPromptBuilder::track
+[`track_entry(&mut self, name, prompt)`]: crate::SystemPromptBuilder::track_entry
[`working_directory(self, path)`]: crate::SystemPromptBuilder::working_directory
[`allowed_paths(self, resolver)`]: crate::SystemPromptBuilder::allowed_paths
[`add_context(self, name, context)`]: crate::SystemPromptBuilder::add_context
@@ -351,6 +379,13 @@ let key = resolver.resolve("OPENAI_API_KEY");
[`build(self)`]: crate::SystemPromptBuilder::build
[`context`]: crate::context
[`ToolContext`]: crate::context::ToolContext
+[`ToolFactory`]: crate::ToolFactory
+[`ToolFactory::create`]: crate::ToolFactory::create
+[`ToolBuildContext`]: crate::ToolBuildContext
+[`CustomToolRegistry`]: crate::CustomToolRegistry
+[`SharedToolRegistry`]: crate::SharedToolRegistry
+[`ToolCatalogEntry`]: crate::ToolCatalogEntry
+[`ToolCatalogKind`]: crate::ToolCatalogKind
[`PathResolver`]: crate::PathResolver
[`AbsolutePathResolver`]: crate::AbsolutePathResolver
[`AllowedGlobResolver`]: crate::path::AllowedGlobResolver
diff --git a/src/reloaded-code-core/src/context/mod.rs b/src/reloaded-code-core/src/context/mod.rs
index c33138a..b0ab256 100644
--- a/src/reloaded-code-core/src/context/mod.rs
+++ b/src/reloaded-code-core/src/context/mod.rs
@@ -18,9 +18,7 @@
//! struct NotesTool;
//!
//! impl ToolContext for ReadTool {
-//! fn name(&self) -> &'static str {
-//! "read"
-//! }
+//! fn name(&self) -> &'static str { "read" }
//!
//! fn context(&self) -> ToolPrompt {
//! ToolPrompt::Read {
@@ -31,9 +29,7 @@
//! }
//!
//! impl ToolContext for NotesTool {
-//! fn name(&self) -> &'static str {
-//! "notes"
-//! }
+//! fn name(&self) -> &'static str { "notes" }
//!
//! fn context(&self) -> ToolPrompt {
//! ToolPrompt::Static("Use this tool for short project notes.")
@@ -72,9 +68,7 @@ pub const GITHUB_CLI: &str = include_str!("github_cli.txt");
/// struct MyTool;
///
/// impl ToolContext for MyTool {
-/// fn name(&self) -> &'static str {
-/// "mytool"
-/// }
+/// fn name(&self) -> &'static str { "mytool" }
///
/// fn context(&self) -> ToolPrompt {
/// ToolPrompt::Static("Instructions for using MyTool...")
diff --git a/src/reloaded-code-core/src/custom_tool/factory.rs b/src/reloaded-code-core/src/custom_tool/factory.rs
new file mode 100644
index 0000000..64ff1bc
--- /dev/null
+++ b/src/reloaded-code-core/src/custom_tool/factory.rs
@@ -0,0 +1,49 @@
+//! Trait and context for creating tools at build time.
+
+use crate::context::ToolContext;
+use crate::tool_context::ToolBuildContext;
+use std::any::Any;
+
+/// 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.
+///
+/// # 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))
+/// }
+/// }
+/// ```
+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;
+}
diff --git a/src/reloaded-code-core/src/custom_tool/mod.rs b/src/reloaded-code-core/src/custom_tool/mod.rs
new file mode 100644
index 0000000..5cdbf0f
--- /dev/null
+++ b/src/reloaded-code-core/src/custom_tool/mod.rs
@@ -0,0 +1,120 @@
+//! 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.
+//!
+//! # Public API
+//!
+//! - [`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.
+//! - [`ToolBuildContext`] - Context passed to [`ToolFactory::create`]. Built-in
+//! tools get this too, plus whatever extra dependencies they need.
+//! - [`CustomToolRegistry`] - Registry of custom tool factories.
+//! - [`SharedToolRegistry`] - Shared wrapper around a registry for cheap cloning.
+//!
+//! # Usage
+//!
+//! ```rust
+//! use reloaded_code_core::{CustomToolRegistry, ToolBuildContext, ToolFactory};
+//! use reloaded_code_core::context::{ToolContext, ToolPrompt};
+//! use std::any::Any;
+//!
+//! struct MyFactory;
+//! struct MyTool;
+//!
+//! impl ToolContext for MyFactory {
+//! fn name(&self) -> &'static str { "my_tool" }
+//! fn context(&self) -> ToolPrompt {
+//! ToolPrompt::Static("Use my_tool to do things.")
+//! }
+//! }
+//!
+//! impl ToolFactory for MyFactory {
+//! fn create(&self, _ctx: &ToolBuildContext) -> Box {
+//! Box::new(MyTool)
+//! }
+//! }
+//!
+//! let mut registry = CustomToolRegistry::new();
+//! registry.insert(MyFactory);
+//! assert!(registry.get("my_tool").is_some());
+//! ```
+
+pub(crate) mod factory;
+pub(crate) mod registry;
+
+pub use crate::tool_context::ToolBuildContext;
+pub use factory::ToolFactory;
+pub use registry::{CustomToolRegistry, SharedToolRegistry};
+
+#[cfg(test)]
+pub(crate) mod test_stubs;
+
+#[cfg(test)]
+mod tests {
+ use super::test_stubs::{EchoFactory, TestFactory};
+ use super::*;
+ use crate::context::ToolContext;
+ use crate::context::ToolPrompt;
+
+ #[test]
+ fn registry_inserts_and_retrieves_factory() {
+ let mut registry = CustomToolRegistry::new();
+ assert!(registry.is_empty());
+
+ registry.insert(EchoFactory::new("echo"));
+ assert_eq!(registry.len(), 1);
+ assert!(!registry.is_empty());
+
+ let factory = registry.get("echo").expect("factory should exist");
+ assert_eq!(factory.name(), "echo");
+ }
+
+ #[test]
+ fn registry_returns_none_for_unknown_name() {
+ let registry = CustomToolRegistry::new();
+ assert!(registry.get("missing").is_none());
+ }
+
+ #[test]
+ fn registry_insert_replaces_existing() {
+ let mut registry = CustomToolRegistry::new();
+ registry.insert(EchoFactory::new("tool_a"));
+ registry.insert(EchoFactory::new("tool_a"));
+ assert_eq!(registry.len(), 1);
+ }
+
+ #[test]
+ fn factory_create_returns_boxed_value() {
+ 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);
+ }
+
+ #[test]
+ fn factory_context_returns_prompt() {
+ let factory = EchoFactory::new("echo");
+ assert!(matches!(factory.context(), ToolPrompt::Static(_)));
+ }
+
+ #[test]
+ fn factory_context_can_skip_guidance_with_empty_static_prompt() {
+ let factory = TestFactory::new("no_context", "");
+ assert!(matches!(factory.context(), ToolPrompt::Static("")));
+ }
+
+ #[test]
+ fn shared_registry_clones_and_accesses_factories() {
+ let mut registry = CustomToolRegistry::new();
+ registry.insert(EchoFactory::new("echo"));
+ let shared = SharedToolRegistry::from_registry(registry);
+
+ let cloned = shared.clone();
+ assert!(cloned.get("echo").is_some());
+ assert_eq!(cloned.len(), 1);
+ }
+}
diff --git a/src/reloaded-code-core/src/custom_tool/registry.rs b/src/reloaded-code-core/src/custom_tool/registry.rs
new file mode 100644
index 0000000..e26eacd
--- /dev/null
+++ b/src/reloaded-code-core/src/custom_tool/registry.rs
@@ -0,0 +1,103 @@
+//! Registries for storing and looking up custom tool factories.
+
+use super::factory::ToolFactory;
+use std::collections::HashMap;
+use std::ops::Deref;
+use std::sync::Arc;
+
+/// Registry of custom tool factories, keyed by tool name.
+#[derive(Default)]
+pub struct CustomToolRegistry {
+ factories: HashMap<&'static str, Box>,
+}
+
+impl std::fmt::Debug for CustomToolRegistry {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("CustomToolRegistry")
+ .field("factories", &self.factories.keys().collect::>())
+ .finish()
+ }
+}
+
+impl CustomToolRegistry {
+ /// Creates an empty registry.
+ #[inline]
+ #[must_use]
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Inserts a custom tool factory, keyed by [`ToolContext::name`].
+ ///
+ /// If a factory with the same name already exists, it is replaced.
+ ///
+ /// [`ToolContext::name`]: crate::ToolContext::name
+ pub fn insert(&mut self, factory: impl ToolFactory + 'static) {
+ self.factories.insert(factory.name(), Box::new(factory));
+ }
+
+ /// Looks up a factory by tool name.
+ ///
+ /// Returns `None` when no factory is registered under `name`.
+ #[inline]
+ pub fn get(&self, name: &str) -> Option<&dyn ToolFactory> {
+ self.factories.get(name).map(|f| f.as_ref())
+ }
+
+ /// Returns `true` if the registry contains no factories.
+ #[inline]
+ pub fn is_empty(&self) -> bool {
+ self.factories.is_empty()
+ }
+
+ /// Returns the number of registered factories.
+ #[inline]
+ pub fn len(&self) -> usize {
+ self.factories.len()
+ }
+}
+
+/// Shared wrapper around a [`CustomToolRegistry`], cheaply cloneable via [`Arc`].
+///
+/// Cloning shares the same underlying map, making it cheap to pass through
+/// runtime builders and framework adapters.
+#[derive(Debug, Clone)]
+pub struct SharedToolRegistry {
+ inner: Arc,
+}
+
+impl SharedToolRegistry {
+ /// Creates an empty registry.
+ #[inline]
+ #[must_use]
+ pub fn new() -> Self {
+ Self {
+ inner: Arc::new(CustomToolRegistry::new()),
+ }
+ }
+
+ /// Creates a shared registry from a populated [`CustomToolRegistry`].
+ #[inline]
+ #[must_use]
+ pub fn from_registry(registry: CustomToolRegistry) -> Self {
+ Self {
+ inner: Arc::new(registry),
+ }
+ }
+}
+
+impl Deref for SharedToolRegistry {
+ type Target = CustomToolRegistry;
+
+ #[inline]
+ fn deref(&self) -> &Self::Target {
+ &self.inner
+ }
+}
+
+impl Default for SharedToolRegistry {
+ #[inline]
+ fn default() -> Self {
+ Self::new()
+ }
+}
diff --git a/src/reloaded-code-core/src/custom_tool/test_stubs.rs b/src/reloaded-code-core/src/custom_tool/test_stubs.rs
new file mode 100644
index 0000000..560be17
--- /dev/null
+++ b/src/reloaded-code-core/src/custom_tool/test_stubs.rs
@@ -0,0 +1,65 @@
+//! Shared test stubs for custom tool tests.
+
+use super::{ToolBuildContext, ToolFactory};
+use crate::context::{ToolContext, ToolPrompt};
+use std::any::Any;
+
+/// Minimal factory returning a configurable prompt and empty boxed value.
+pub(crate) struct TestFactory {
+ pub(crate) tool_name: &'static str,
+ pub(crate) prompt: &'static str,
+}
+
+impl TestFactory {
+ pub(crate) fn new(name: &'static str, prompt: &'static str) -> Self {
+ Self {
+ tool_name: name,
+ prompt,
+ }
+ }
+}
+
+impl ToolContext for TestFactory {
+ fn name(&self) -> &'static str {
+ self.tool_name
+ }
+
+ fn context(&self) -> ToolPrompt {
+ ToolPrompt::Static(self.prompt)
+ }
+}
+
+impl ToolFactory for TestFactory {
+ fn create(&self, _ctx: &ToolBuildContext) -> Box {
+ Box::new(())
+ }
+}
+
+/// Factory that returns a downcastable integer for registry tests.
+pub(crate) struct EchoFactory {
+ /// Tool name passed to [`ToolContext::name`].
+ pub(crate) tool_name: &'static str,
+}
+
+impl EchoFactory {
+ /// Creates a new [`EchoFactory`] with the given tool name.
+ pub(crate) fn new(name: &'static str) -> Self {
+ Self { tool_name: name }
+ }
+}
+
+impl ToolContext for EchoFactory {
+ fn name(&self) -> &'static str {
+ self.tool_name
+ }
+
+ fn context(&self) -> ToolPrompt {
+ ToolPrompt::Static("echo tool prompt")
+ }
+}
+
+impl ToolFactory for EchoFactory {
+ fn create(&self, _ctx: &ToolBuildContext) -> Box {
+ Box::new(42_usize)
+ }
+}
diff --git a/src/reloaded-code-core/src/lib.rs b/src/reloaded-code-core/src/lib.rs
index 32698e0..ebd5657 100644
--- a/src/reloaded-code-core/src/lib.rs
+++ b/src/reloaded-code-core/src/lib.rs
@@ -9,6 +9,7 @@ compile_error!("Either an async runtime (e.g., `tokio`) or `blocking` feature mu
pub mod context;
pub mod credentials;
+pub mod custom_tool;
pub mod error;
pub mod fs;
pub mod models;
@@ -17,6 +18,7 @@ pub mod path;
pub mod permissions;
pub mod permissions_ext;
pub mod system_prompt;
+pub mod tool_catalog;
pub mod tool_context;
pub mod tool_metadata;
pub mod tools;
@@ -27,11 +29,12 @@ mod internal;
pub use context::ToolContext;
pub use credentials::{CredentialLookup, CredentialResolver};
+pub use custom_tool::{CustomToolRegistry, SharedToolRegistry, ToolBuildContext, ToolFactory};
pub use error::{ToolError, ToolResult};
pub use output::ToolOutput;
pub use path::{AbsolutePathResolver, AllowedGlobResolver, AllowedPathResolver, PathResolver};
pub use system_prompt::SystemPromptBuilder;
-pub use tool_context::ToolBuildContext;
+pub use tool_catalog::{default_tools, ToolCatalogEntry, ToolCatalogKind};
pub use workspace::resolve_workspace_root;
// Re-export tools (always available, sync or async based on runtime feature)
diff --git a/src/reloaded-code-core/src/system_prompt.rs b/src/reloaded-code-core/src/system_prompt.rs
index 6532f01..df5e5db 100644
--- a/src/reloaded-code-core/src/system_prompt.rs
+++ b/src/reloaded-code-core/src/system_prompt.rs
@@ -123,6 +123,19 @@ impl SystemPromptBuilder {
tool
}
+ /// Records a raw context entry without wrapping a tool.
+ ///
+ /// Use this when you have name and prompt but no tool instance to wrap
+ /// via [`Self::track`].
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - Tool name for the section header (e.g., `"web_search"`).
+ /// * `prompt` - Guidance to render for this tool.
+ pub fn track_entry(&mut self, name: &'static str, prompt: ToolPrompt) {
+ self.entries.push(ContextEntry { name, prompt });
+ }
+
/// Adds supplemental context to the system prompt.
///
/// Supplemental context appears in a separate "Supplemental Context" section
diff --git a/src/reloaded-code-agents/src/runtime/tool_catalog.rs b/src/reloaded-code-core/src/tool_catalog.rs
similarity index 79%
rename from src/reloaded-code-agents/src/runtime/tool_catalog.rs
rename to src/reloaded-code-core/src/tool_catalog.rs
index c13c13c..f42ad22 100644
--- a/src/reloaded-code-agents/src/runtime/tool_catalog.rs
+++ b/src/reloaded-code-core/src/tool_catalog.rs
@@ -1,23 +1,16 @@
-//! Lists which tools your agents can use.
+//! Tool catalog entries shared across runtimes and framework adapters.
//!
-//! Each [`ToolCatalogEntry`] pairs a tool name with its type ([`ToolCatalogKind`]).
-//!
-//! # Public API
-//!
-//! - [`ToolCatalogEntry`] - One tool the runtime can provide to agents
-//! - [`ToolCatalogKind`] - The tools your agents can use
-//! - [`default_tools()`] - The standard tool set
-//!
-//! The default tools are: read, write, edit, glob, grep, bash, webfetch, todoread,
-//! todowrite, task.
+//! Each [`ToolCatalogEntry`] pairs a model-facing tool name with its
+//! [`ToolCatalogKind`]. The catalog describes what can be materialized; adapter
+//! crates decide how each kind is built for their framework.
-use reloaded_code_core::tool_metadata::{
+use crate::tool_metadata::{
bash as bash_meta, edit as edit_meta, glob as glob_meta, grep as grep_meta, read as read_meta,
task as task_meta, todo_read as todo_read_meta, todo_write as todo_write_meta,
webfetch as webfetch_meta, write as write_meta,
};
-/// One tool the runtime can provide to agents.
+/// One tool an integration can provide.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ToolCatalogEntry {
/// Tool name exposed to models.
@@ -28,12 +21,13 @@ pub struct ToolCatalogEntry {
impl ToolCatalogEntry {
/// Creates a tool entry from its name and kind.
+ #[must_use]
pub const fn new(name: &'static str, kind: ToolCatalogKind) -> Self {
Self { name, kind }
}
}
-/// The tools your agents can use.
+/// Standard and custom tool kinds understood by reloaded-code adapters.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ToolCatalogKind {
@@ -55,8 +49,10 @@ pub enum ToolCatalogKind {
TodoRead,
/// Create and update todo items.
TodoWrite,
- /// Delegate to subagent via Task tool.
+ /// Delegate to another runtime target via Task.
Task,
+ /// User-defined custom tool.
+ Custom,
}
const DEFAULT_TOOLS: [ToolCatalogEntry; 10] = [
@@ -73,6 +69,7 @@ const DEFAULT_TOOLS: [ToolCatalogEntry; 10] = [
];
/// Returns the standard tool set.
+#[must_use]
pub fn default_tools() -> Vec {
DEFAULT_TOOLS.to_vec()
}
@@ -80,7 +77,7 @@ pub fn default_tools() -> Vec {
#[cfg(test)]
mod tests {
use super::{default_tools, ToolCatalogEntry, ToolCatalogKind};
- use reloaded_code_core::tool_metadata::{
+ use crate::tool_metadata::{
bash as bash_meta, edit as edit_meta, glob as glob_meta, grep as grep_meta,
read as read_meta, task as task_meta, todo_read as todo_read_meta,
todo_write as todo_write_meta, webfetch as webfetch_meta, write as write_meta,
@@ -104,4 +101,11 @@ mod tests {
],
);
}
+
+ #[test]
+ fn custom_variant_creates_catalog_entry() {
+ let entry = ToolCatalogEntry::new("web_search", ToolCatalogKind::Custom);
+ assert_eq!(entry.name, "web_search");
+ assert_eq!(entry.kind, ToolCatalogKind::Custom);
+ }
}
diff --git a/src/reloaded-code-serdesai/README.md b/src/reloaded-code-serdesai/README.md
index a5b6862..02d9c87 100644
--- a/src/reloaded-code-serdesai/README.md
+++ b/src/reloaded-code-serdesai/README.md
@@ -162,6 +162,86 @@ If you already have your own `ModelCatalog`, you can use that instead of
See [examples/serdesai-agents.rs](examples/serdesai-agents.rs) and
[examples/serdesai-task.rs](examples/serdesai-task.rs).
+## 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`]:
+
+```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;
+
+// 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(),
+ )
+ }
+
+ async fn call(&self, _ctx: &RunContext<()>, args: serde_json::Value) -> ToolResult {
+ let msg = args["message"].as_str().unwrap_or_default();
+ Ok(ToolReturn::text(msg))
+ }
+}
+
+// 2. Provide name and prompt guidance via ToolContext
+struct EchoFactory;
+impl ToolContext for EchoFactory {
+ fn name(&self) -> &'static str { "echo" }
+ fn context(&self) -> ToolPrompt {
+ ToolPrompt::Static("Use echo to repeat a message.")
+ }
+}
+
+// 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>)
+ }
+}
+
+// 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(())
+}
+```
+
+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>`
+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`.
+
## Linux Shell Sandboxing
Sandboxing is **not enabled by default** for the `bash` tool - it runs
@@ -218,3 +298,4 @@ Apache 2.0
[models.dev]: https://models.dev
[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
diff --git a/src/reloaded-code-serdesai/src/agent_ext.rs b/src/reloaded-code-serdesai/src/agent_ext.rs
index 5da2502..22ee9a6 100644
--- a/src/reloaded-code-serdesai/src/agent_ext.rs
+++ b/src/reloaded-code-serdesai/src/agent_ext.rs
@@ -50,6 +50,26 @@ impl> ToolExecutor for ToolAsEx
}
}
+/// Adapter for boxed trait object tools, similar to [`ToolAsExecutor`] but
+/// for dynamically dispatched tools where the concrete type is not known
+/// at compile time.
+struct DynToolAsExecutor(Box + Send + Sync>);
+
+#[async_trait]
+impl ToolExecutor for DynToolAsExecutor {
+ async fn execute(
+ &self,
+ args: JsonValue,
+ ctx: &AgentRunContext,
+ ) -> 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());
+
+ self.0.call(&tools_ctx, args).await
+ }
+}
+
/// Extension trait for [`AgentBuilder`] to add tools that implement [`Tool`].
pub trait AgentBuilderExt {
/// Add a tool that implements the [`Tool`] trait.
@@ -73,6 +93,16 @@ pub trait AgentBuilderExt {
/// # }
/// ```
fn tool + 'static>(self, tool: T) -> Self;
+
+ /// Add a boxed trait object tool.
+ ///
+ /// This is useful for dynamically created tools where the concrete type
+ /// is not known at compile time (e.g., custom tools from a factory).
+ fn tool_dyn(
+ self,
+ definition: serdes_ai::ToolDefinition,
+ tool: Box + Send + Sync>,
+ ) -> Self;
}
impl AgentBuilderExt for AgentBuilder
@@ -84,6 +114,14 @@ where
let definition = tool.definition();
self.tool_with_executor(definition, ToolAsExecutor(tool))
}
+
+ fn tool_dyn(
+ self,
+ definition: serdes_ai::ToolDefinition,
+ tool: Box + Send + Sync>,
+ ) -> Self {
+ self.tool_with_executor(definition, DynToolAsExecutor(tool))
+ }
}
/// Extension for converting [`ToolError`] results into [`AgentBuildError`].
diff --git a/src/reloaded-code-serdesai/src/agent_runtime/build.rs b/src/reloaded-code-serdesai/src/agent_runtime/build.rs
index 75e3957..24366fa 100644
--- a/src/reloaded-code-serdesai/src/agent_runtime/build.rs
+++ b/src/reloaded-code-serdesai/src/agent_runtime/build.rs
@@ -15,8 +15,9 @@ use crate::{
use indexmap::IndexMap;
use reloaded_code_agents::{
AgentRuntime, AgentToolSettings, ModelResolutionError, PermissionRule, TaskTargetSummary,
- ToolCatalogEntry, ToolCatalogKind, build_resolver_for_tool,
+ build_resolver_for_tool,
};
+use reloaded_code_core::context::ToolPrompt;
use reloaded_code_core::permissions::Ruleset;
use reloaded_code_core::tool_context::ToolBuildContext;
use reloaded_code_core::tool_metadata::{
@@ -26,7 +27,10 @@ use reloaded_code_core::tool_metadata::{
use reloaded_code_core::tools::{
GlobSettings, GrepFormattingSettings, GrepSettings, ReadSettings, WebFetchSettings,
};
-use reloaded_code_core::{CredentialLookup, ToolError, models::ModelCatalog};
+use reloaded_code_core::{
+ CredentialLookup, SharedToolRegistry, ToolCatalogEntry, ToolCatalogKind, ToolError,
+ models::ModelCatalog,
+};
use serdes_ai::AgentBuilder;
use serdes_ai_models::BoxedModel;
use std::path::Path;
@@ -48,6 +52,18 @@ pub enum AgentBuildError {
/// The missing agent name.
name: Box,
},
+ /// A custom tool catalog entry has no matching registered factory.
+ #[error("no factory registered for custom tool `{name}`")]
+ UnknownCustomTool {
+ /// 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.
+ name: Box,
+ },
/// The runtime contains a tool kind this adapter cannot materialise.
#[error("tool `{name}` is not supported")]
UnsupportedToolKind {
@@ -158,7 +174,13 @@ where
/// # Errors
///
/// Returns [`AgentBuildError::UnsupportedToolKind`] when the runtime catalog contains an
-/// unrecognized [`ToolCatalogKind`] variant (this function only handles standard tools).
+/// unrecognized [`ToolCatalogKind`] variant.
+///
+/// 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::ToolSettingsValidation`] when resolver creation or settings
/// building fails for any tool, including:
@@ -171,6 +193,7 @@ pub(super) fn attach_standard_tools<'a, C>(
task_handle: Option<&TaskHandle>,
workspace_root: &Path,
bash_sandbox: Option<&Arc>,
+ custom_tool_registry: &SharedToolRegistry,
) -> Result<(AgentBuilder<(), String>, SystemPromptBuilder), AgentBuildError>
where
C: CredentialLookup + Send + Sync + 'static,
@@ -196,9 +219,7 @@ where
source: ToolError::InvalidPath(e.to_string()),
})?;
- // Use pre-built permission ruleset from PreparedBuild for non-file tools.
- let permission = prepared.permission.clone();
- let permission_config = &prepared.permission_config;
+ let permission_config = prepared.permission_config;
for entry in prepared.tools.iter() {
match entry.kind {
@@ -247,7 +268,7 @@ where
#[allow(unused_mut)]
let mut tool = BashTool::new()
.with_timeouts(Some(settings.timeout_ms), Some(settings.max_timeout_ms))
- .with_permission(permission.clone());
+ .with_permission(prepared.permission.clone());
#[cfg(all(feature = "linux-bubblewrap", target_os = "linux"))]
if let Some(profile) = bash_sandbox {
tool = tool.with_linux_bwrap(profile.clone());
@@ -275,6 +296,39 @@ where
)));
}
}
+ ToolCatalogKind::Custom => {
+ let factory = custom_tool_registry.get(entry.name).ok_or_else(|| {
+ AgentBuildError::UnknownCustomTool {
+ name: entry.name.into(),
+ }
+ })?;
+
+ // 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();
+ // 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);
+ }
+ // Unwrap the double box to get Box>.
+ let tool = *double_boxed;
+ builder = builder.tool_dyn(tool.definition(), tool);
+ }
_ => {
return Err(AgentBuildError::UnsupportedToolKind {
name: entry.name.into(),
@@ -334,10 +388,10 @@ mod tests {
use ahash::AHashMap;
use indexmap::IndexMap;
use reloaded_code_agents::{
- AgentCatalog, AgentConfig, AgentDefaults, AgentMode, AgentRuntimeBuilder,
+ AgentCatalog, AgentConfig, AgentDefaults, AgentMode, AgentRuntime, AgentRuntimeBuilder,
AgentToolSettings, PermissionRule,
};
- use reloaded_code_core::CredentialResolver;
+ use reloaded_code_core::context::{ToolContext, ToolPrompt};
use reloaded_code_core::models::{
Modality, ModelCatalog, ModelInfo, ProviderIdx, ProviderInfo, ProviderModelSource,
ProviderSource, ProviderType,
@@ -346,6 +400,10 @@ mod tests {
use reloaded_code_core::tool_metadata::{
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,
+ };
use serdes_ai::AgentBuilder;
use serdes_ai_models::MockModel;
use std::collections::HashSet;
@@ -357,6 +415,17 @@ mod tests {
prepared: &super::PreparedBuild<'_>,
name: &str,
) -> serdes_ai::Agent<(), String> {
+ build_mock_agent(prepared, &SharedToolRegistry::new(), name)
+ .expect("build should succeed")
+ .0
+ }
+
+ /// Builds a mock agent from prepared state and returns the agent plus prompt.
+ fn build_mock_agent(
+ prepared: &super::PreparedBuild<'_>,
+ registry: &SharedToolRegistry,
+ name: &str,
+ ) -> Result<(serdes_ai::Agent<(), String>, String), AgentBuildError> {
let workspace_root = reloaded_code_core::resolve_workspace_root().expect("workspace root");
let (builder, prompt_builder) = attach_standard_tools::(
AgentBuilder::<(), String>::new(MockModel::new(name)),
@@ -364,9 +433,11 @@ mod tests {
None,
&workspace_root,
None,
- )
- .expect("build should succeed");
- builder.system_prompt(prompt_builder.build()).build()
+ registry,
+ )?;
+ let prompt = prompt_builder.build();
+ let agent = builder.system_prompt(prompt.clone()).build();
+ Ok((agent, prompt))
}
/// Creates a minimal agent config with no model or sampling overrides.
@@ -453,6 +524,45 @@ mod tests {
credentials
}
+ /// Builds a test runtime with one custom tool and read permission.
+ fn custom_tool_runtime(
+ agent_name: &str,
+ custom_name: &'static str,
+ factory: impl ToolFactory + 'static,
+ ) -> Result {
+ AgentRuntimeBuilder::new()
+ .catalog(AgentCatalog::from_entries([agent(
+ agent_name,
+ allow_tools(&[read_meta::NAME, custom_name]),
+ "prompt",
+ )]))
+ .tools(vec![
+ ToolCatalogEntry::new(read_meta::NAME, ToolCatalogKind::Read),
+ ToolCatalogEntry::new(custom_name, ToolCatalogKind::Custom),
+ ])
+ .custom_tool(factory)
+ .defaults(AgentDefaults::with_model("openrouter/openai/gpt-4.1-mini"))
+ .build()
+ }
+
+ /// Prepares a built agent and final system prompt for assertion.
+ fn attach_test_agent(
+ runtime: &AgentRuntime,
+ agent_name: &str,
+ ) -> Result<(serdes_ai::Agent<(), String>, String), AgentBuildError> {
+ let catalog = catalog();
+ let credentials = credentials();
+ let prepared = prepare_build(runtime, agent_name, &catalog, &credentials, true)?;
+ build_mock_agent(&prepared, runtime.custom_tool_registry(), agent_name)
+ }
+
+ /// Asserts a tool name is present on the built agent.
+ fn assert_tool_attached(agent: &serdes_ai::Agent<(), String>, name: &str) {
+ let names: std::collections::HashSet<&str> =
+ agent.tools().iter().map(|t| t.name()).collect();
+ assert!(names.contains(name), "{name} tool should be attached");
+ }
+
#[test]
fn build_filters_tools_by_permission() -> TestResult {
let credentials = credentials();
@@ -641,4 +751,81 @@ mod tests {
);
Ok(())
}
+
+ #[test]
+ fn build_returns_unknown_custom_tool_error() -> TestResult {
+ let tools = vec![
+ ToolCatalogEntry::new(read_meta::NAME, ToolCatalogKind::Read),
+ ToolCatalogEntry::new("custom_missing", ToolCatalogKind::Custom),
+ ];
+
+ let runtime = AgentRuntimeBuilder::new()
+ .catalog(AgentCatalog::from_entries([agent(
+ "tester",
+ allow_tools(&[read_meta::NAME, "custom_missing"]),
+ "prompt",
+ )]))
+ .tools(tools)
+ .defaults(AgentDefaults::with_model("openrouter/openai/gpt-4.1-mini"))
+ .build()?;
+
+ let result = attach_test_agent(&runtime, "tester");
+ assert!(
+ matches!(&result, Err(AgentBuildError::UnknownCustomTool { name } ) if &**name == "custom_missing"),
+ "expected UnknownCustomTool error for custom_missing, got a different result"
+ );
+ Ok(())
+ }
+
+ #[test]
+ fn build_returns_error_on_custom_tool_downcast_failure() -> TestResult {
+ use std::any::Any;
+
+ struct BadFactory;
+ impl ToolContext for BadFactory {
+ fn name(&self) -> &'static str {
+ "bad_tool"
+ }
+ fn context(&self) -> ToolPrompt {
+ ToolPrompt::Static("Bad tool guidance.")
+ }
+ }
+ impl ToolFactory for BadFactory {
+ fn create(&self, _ctx: &ToolBuildContext) -> Box {
+ // Returns wrong type - not Box>
+ Box::new(42_usize)
+ }
+ }
+
+ 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"
+ );
+ Ok(())
+ }
+
+ #[test]
+ fn build_attaches_custom_tool_with_prompt() -> TestResult {
+ use crate::agent_runtime::test_stubs::SerdesTestFactory;
+
+ let runtime = custom_tool_runtime(
+ "pinger",
+ "ping",
+ SerdesTestFactory::new("ping", "Use ping to check connectivity.", "pong"),
+ )?;
+ let attached = attach_test_agent(&runtime, "pinger");
+ let (agent, prompt) =
+ attached.expect("custom tool build should succeed with valid factory");
+
+ assert_tool_attached(&agent, "ping");
+ assert_tool_attached(&agent, read_meta::NAME);
+
+ assert!(
+ prompt.contains("Use ping to check connectivity"),
+ "custom tool prompt guidance should appear in system prompt"
+ );
+ Ok(())
+ }
}
diff --git a/src/reloaded-code-serdesai/src/agent_runtime/mod.rs b/src/reloaded-code-serdesai/src/agent_runtime/mod.rs
index 0b3222e..dd679e2 100644
--- a/src/reloaded-code-serdesai/src/agent_runtime/mod.rs
+++ b/src/reloaded-code-serdesai/src/agent_runtime/mod.rs
@@ -1,7 +1,7 @@
//! SerdesAI adapter for the generic agent runtime.
//!
-//! The data-only runtime foundation lives in [`reloaded_code_agents`]. This
-//! module re-exports those generic types and adds SerdesAI-specific build
+//! The data-only runtime foundation lives in `reloaded-code-agents`. This
+//! module re-exports agent runtime types and adds SerdesAI-specific build
//! orchestration through [`AgentBuildContext`].
//!
//! # Public API
@@ -12,11 +12,13 @@ mod build;
mod model;
mod provider_bridge;
mod task;
+#[cfg(test)]
+mod test_stubs;
pub use build::AgentBuildError;
pub use reloaded_code_agents::{
AgentDefaults, AgentRuntime, AgentRuntimeBuilder, ModelResolutionError, ResolvedModel,
- ToolCatalogEntry, ToolCatalogKind, default_tools, resolve_model_with_catalog,
+ resolve_model_with_catalog,
};
pub use task::AgentBuildContext;
pub(crate) use task::{TaskBuildContext, build_agent};
diff --git a/src/reloaded-code-serdesai/src/agent_runtime/task.rs b/src/reloaded-code-serdesai/src/agent_runtime/task.rs
index 04920ee..4c06749 100644
--- a/src/reloaded-code-serdesai/src/agent_runtime/task.rs
+++ b/src/reloaded-code-serdesai/src/agent_runtime/task.rs
@@ -172,6 +172,10 @@ where
/// validation fails during the build.
/// - Returns [`AgentBuildError::UnsupportedToolKind`] when the runtime
/// 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.
#[inline]
pub fn build(&self, name: &str) -> Result, AgentBuildError> {
build_agent(Arc::clone(&self.context), name, 0)
@@ -300,6 +304,10 @@ where
/// validation fails during the build.
/// - Returns [`AgentBuildError::UnsupportedToolKind`] when the runtime
/// 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.
pub(crate) fn build_agent(
context: Arc>,
name: &str,
@@ -337,6 +345,7 @@ where
Some(&task_handle),
&context.workspace_root,
sandbox_ref,
+ context.runtime.custom_tool_registry(),
)?;
Ok(builder.system_prompt(prompt_builder.build()).build())
}
diff --git a/src/reloaded-code-serdesai/src/agent_runtime/test_stubs.rs b/src/reloaded-code-serdesai/src/agent_runtime/test_stubs.rs
new file mode 100644
index 0000000..55c3023
--- /dev/null
+++ b/src/reloaded-code-serdesai/src/agent_runtime/test_stubs.rs
@@ -0,0 +1,79 @@
+//! 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;
+
+/// A minimal `serdes_ai::Tool<()>` that returns a configurable text response.
+struct SerdesTestTool {
+ name: &'static str,
+ response: &'static str,
+}
+
+#[async_trait]
+impl serdes_ai::Tool<()> for SerdesTestTool {
+ fn definition(&self) -> ToolDefinition {
+ ToolDefinition::new(self.name, self.name)
+ }
+
+ async fn call(&self, _ctx: &RunContext<()>, _args: serde_json::Value) -> serdes_ai::ToolResult {
+ Ok(ToolReturn::text(self.response))
+ }
+}
+
+/// A `ToolFactory` that creates a [`SerdesTestTool`] and returns it as a
+/// double-boxed `Box`.
+///
+/// `name` and `prompt` are surfaced via `ToolContext` for system-prompt
+/// guidance injection. `response` is returned by the tool's `call()`.
+#[derive(Debug)]
+pub struct SerdesTestFactory {
+ /// Tool name passed to `ToolContext::name()` and `ToolDefinition::new()`.
+ pub name: &'static str,
+ /// Prompt text passed to `ToolContext::context()`.
+ pub prompt: &'static str,
+ /// Text returned by `SerdesTestTool::call()`.
+ pub response: &'static str,
+}
+
+impl SerdesTestFactory {
+ /// Creates a new factory that produces a tool named `name`, with system-prompt
+ /// guidance `prompt`, and `call()` returning `response`.
+ #[inline]
+ pub fn new(name: &'static str, prompt: &'static str, response: &'static str) -> Self {
+ Self {
+ name,
+ prompt,
+ response,
+ }
+ }
+}
+
+impl ToolContext for SerdesTestFactory {
+ #[inline]
+ fn name(&self) -> &'static str {
+ self.name
+ }
+
+ #[inline]
+ fn context(&self) -> ToolPrompt {
+ ToolPrompt::Static(self.prompt)
+ }
+}
+
+impl ToolFactory for SerdesTestFactory {
+ #[inline]
+ fn create(&self, _ctx: &ToolBuildContext) -> Box {
+ let tool: Box> = Box::new(SerdesTestTool {
+ name: self.name,
+ response: self.response,
+ });
+ Box::new(tool)
+ }
+}
diff --git a/src/reloaded-code-serdesai/src/lib.rs b/src/reloaded-code-serdesai/src/lib.rs
index fcd82ad..95169b8 100644
--- a/src/reloaded-code-serdesai/src/lib.rs
+++ b/src/reloaded-code-serdesai/src/lib.rs
@@ -8,7 +8,7 @@ pub mod task;
pub mod tools;
/// Re-export core types for convenience.
-pub use reloaded_code_core::{ToolError, ToolOutput, ToolResult};
+pub use reloaded_code_core::{TaskSettings, ToolError, ToolOutput, ToolResult};
/// Re-export bash execution mode and mode-aware execution.
pub use reloaded_code_core::{BashExecutionMode, execute_command_with_mode};
@@ -45,5 +45,5 @@ pub use reloaded_code_core::{
pub use agent_runtime::{AgentBuildContext, AgentBuildError};
pub use reloaded_code_agents::{
AgentDefaults, AgentRuntime, AgentRuntimeBuilder, ModelResolutionError, ResolvedModel,
- TaskSettings, ToolCatalogEntry, ToolCatalogKind, default_tools, resolve_model_with_catalog,
+ resolve_model_with_catalog,
};