BareGit

Implement tool registration and schemas

- Add cancellation-aware tool adapters and an ordered tool registry.
- Serialize available tools in LLM requests and cover them with tests.
Author: MetroWind <chris.corsair@gmail.com>
Date: Thu Aug 27 10:58:59 2026 -0700
Commit: 24e62ad1edb0c11490952046b6e1e6e9e9d75fad

Changes

diff --git a/CMakeLists.txt b/CMakeLists.txt
index 0cf4c37..ed3ce40 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -77,6 +77,7 @@ add_library(agent_smith ${AGENT_SMITH_LIBRARY_TYPE}
     src/message.cpp
     src/runtime.cpp
     src/sse_parser.cpp
+    src/tool.cpp
 )
 add_library(agent_smith::agent_smith ALIAS agent_smith)
 
diff --git a/include/agent_smith/agent_smith.hpp b/include/agent_smith/agent_smith.hpp
index 7548b5d..d1f3c2d 100644
--- a/include/agent_smith/agent_smith.hpp
+++ b/include/agent_smith/agent_smith.hpp
@@ -1,9 +1,11 @@
 #pragma once
 
 #include "agent.hpp"
+#include "cancellation.hpp"
 #include "error.hpp"
 #include "llm_client.hpp"
 #include "message.hpp"
 #include "runtime.hpp"
 #include "stream_event.hpp"
+#include "tool.hpp"
 #include "version.hpp"
diff --git a/include/agent_smith/cancellation.hpp b/include/agent_smith/cancellation.hpp
new file mode 100644
index 0000000..5f5d23a
--- /dev/null
+++ b/include/agent_smith/cancellation.hpp
@@ -0,0 +1,57 @@
+#pragma once
+
+#include <atomic>
+#include <memory>
+#include <utility>
+
+namespace agent_smith
+{
+
+/// A copyable, cooperative signal that an in-progress operation should stop.
+class CancellationToken
+{
+public:
+    /// Create a token that is never cancelled.
+    CancellationToken() = default;
+
+    /// Return whether the associated cancellation source has been cancelled.
+    bool cancelled() const noexcept
+    {
+        return state_ != nullptr && state_->load();
+    }
+
+private:
+    friend class CancellationSource;
+
+    explicit CancellationToken(std::shared_ptr<std::atomic_bool> state)
+        : state_(std::move(state))
+    {
+    }
+
+    std::shared_ptr<std::atomic_bool> state_;
+};
+
+/// Owns and triggers a cancellation signal shared with its tokens.
+class CancellationSource
+{
+public:
+    /// Create a source with an initially non-cancelled token.
+    CancellationSource() : state_(std::make_shared<std::atomic_bool>(false)) {}
+
+    /// Return a token that observes this source's cancellation state.
+    CancellationToken token() const
+    {
+        return CancellationToken(state_);
+    }
+
+    /// Request cooperative cancellation for every token from this source.
+    void cancel() noexcept
+    {
+        state_->store(true);
+    }
+
+private:
+    std::shared_ptr<std::atomic_bool> state_;
+};
+
+} // namespace agent_smith
diff --git a/include/agent_smith/llm_client.hpp b/include/agent_smith/llm_client.hpp
index f8f17bc..3ef05b4 100644
--- a/include/agent_smith/llm_client.hpp
+++ b/include/agent_smith/llm_client.hpp
@@ -13,6 +13,7 @@
 #include "message.hpp"
 #include "runtime.hpp"
 #include "stream_event.hpp"
+#include "tool.hpp"
 
 namespace agent_smith
 {
@@ -67,6 +68,9 @@ struct LlmRequest
     /// Ordered conversation history sent to the model.
     std::vector<Message> messages;
 
+    /// Function schemas made available to the model for this request.
+    std::vector<ToolSchema> tools;
+
     /// Sampling values that override the client's defaults.
     SamplingParams sampling;
 };
diff --git a/include/agent_smith/tool.hpp b/include/agent_smith/tool.hpp
new file mode 100644
index 0000000..cf4d50d
--- /dev/null
+++ b/include/agent_smith/tool.hpp
@@ -0,0 +1,288 @@
+#pragma once
+
+#include <chrono>
+#include <concepts>
+#include <functional>
+#include <memory>
+#include <optional>
+#include <string>
+#include <string_view>
+#include <unordered_map>
+#include <utility>
+#include <vector>
+
+#include <nlohmann/json.hpp>
+#include <spdlog/spdlog.h>
+
+#include "cancellation.hpp"
+#include "runtime.hpp"
+
+namespace agent_smith
+{
+
+/// OpenAI-compatible schema and presentation metadata for one tool.
+struct ToolSchema
+{
+    /// Unique function name sent to the LLM provider.
+    std::string name;
+
+    /// Human-readable explanation of the tool's purpose.
+    std::string description;
+
+    /// JSON-Schema object accepted as the tool's argument shape.
+    nlohmann::json parameters;
+};
+
+/// Per-invocation resources and constraints supplied to a tool.
+struct ToolContext
+{
+    /// Cooperative cancellation signal for this invocation.
+    CancellationToken cancel;
+
+    /// Remaining execution-time budget for this invocation.
+    std::chrono::milliseconds budget{0};
+
+    /// Identifier that matches the LLM-generated tool call.
+    std::string call_id;
+
+    /// Optional logger scoped to tool execution.
+    spdlog::logger* logger = nullptr;
+
+    /// Runtime used by tools that need framework services.
+    Runtime* runtime = nullptr;
+};
+
+/// A normalized result returned by one tool invocation.
+struct ToolResult
+{
+    /// Text content supplied to the LLM as the tool result.
+    std::string content;
+
+    /// Whether the tool result represents an expected tool-level failure.
+    bool is_error = false;
+
+    /// Optional structured representation of the result.
+    nlohmann::json structured = nullptr;
+};
+
+/// Per-tool invocation options stored by the registry.
+struct ToolOptions
+{
+    /// Optional maximum execution duration for the tool.
+    std::optional<std::chrono::milliseconds> timeout;
+};
+
+/// Interface implemented by all callable tools.
+class Tool
+{
+public:
+    /// Destroy a tool instance through the interface.
+    virtual ~Tool() = default;
+
+    /// Return the immutable schema advertised for this tool.
+    virtual const ToolSchema& schema() const = 0;
+
+    /// Invoke the tool with parsed JSON arguments and invocation context.
+    virtual mw::Task<mw::E<ToolResult>> invoke(nlohmann::json arguments,
+                                               ToolContext context) = 0;
+};
+
+/// Normalize a text result from a tool callable.
+inline mw::E<ToolResult> normalizeToolResult(std::string value)
+{
+    return ToolResult{
+        .content = std::move(value),
+    };
+}
+
+/// Normalize a C-string result from a tool callable.
+inline mw::E<ToolResult> normalizeToolResult(const char* value)
+{
+    return normalizeToolResult(value == nullptr ? std::string()
+                                                : std::string(value));
+}
+
+/// Normalize structured JSON into both text and structured tool output.
+inline mw::E<ToolResult> normalizeToolResult(nlohmann::json value)
+{
+    const std::string content = value.dump();
+    return ToolResult{
+        .content = content,
+        .structured = std::move(value),
+    };
+}
+
+/// Forward a fully normalized tool result unchanged.
+inline mw::E<ToolResult> normalizeToolResult(ToolResult value)
+{
+    return value;
+}
+
+/// Forward a fallible normalized tool result unchanged.
+inline mw::E<ToolResult> normalizeToolResult(mw::E<ToolResult> value)
+{
+    return value;
+}
+
+/// A callable that finishes synchronously with a normalizable result.
+template <typename F>
+concept SyncToolCallable = requires(F& function, nlohmann::json arguments,
+                                    ToolContext context) {
+    {
+        normalizeToolResult(function(std::move(arguments), std::move(context)))
+    } -> std::same_as<mw::E<ToolResult>>;
+};
+
+/// A callable that already exposes Agent Smith's asynchronous tool result.
+template <typename F>
+concept AsyncToolCallable =
+    requires(F& function, nlohmann::json arguments, ToolContext context) {
+        {
+            function(std::move(arguments), std::move(context))
+        } -> std::same_as<mw::Task<mw::E<ToolResult>>>;
+    };
+
+namespace detail
+{
+
+inline void validateToolSchema(const ToolSchema& schema)
+{
+    if(!schema.parameters.is_object())
+    {
+        spdlog::warn("tool '{}' has non-object JSON Schema parameters",
+                     schema.name.c_str());
+        return;
+    }
+
+    const auto type = schema.parameters.find("type");
+    if(type == schema.parameters.end() || !type->is_string() ||
+       *type != "object")
+    {
+        spdlog::warn("tool '{}' JSON Schema parameters should have type object",
+                     schema.name.c_str());
+    }
+
+    const auto properties = schema.parameters.find("properties");
+    if(properties == schema.parameters.end() || !properties->is_object())
+    {
+        spdlog::warn(
+            "tool '{}' JSON Schema parameters should have object properties",
+            schema.name.c_str());
+    }
+}
+
+template <SyncToolCallable F> class SyncToolAdapter final : public Tool
+{
+public:
+    SyncToolAdapter(ToolSchema schema, F function)
+        : schema_(std::move(schema)), function_(std::move(function))
+    {
+        validateToolSchema(schema_);
+    }
+
+    const ToolSchema& schema() const override
+    {
+        return schema_;
+    }
+
+    mw::Task<mw::E<ToolResult>> invoke(nlohmann::json arguments,
+                                       ToolContext context) override
+    {
+        if(context.runtime == nullptr)
+        {
+            co_return std::unexpected(
+                mw::runtimeError("synchronous tool requires a runtime"));
+        }
+
+        std::function<mw::E<ToolResult>()> work =
+            [function = function_, arguments = std::move(arguments),
+             context = std::move(context)]() mutable
+        {
+            return normalizeToolResult(
+                function(std::move(arguments), std::move(context)));
+        };
+        co_return co_await context.runtime->runOnWorker(std::move(work));
+    }
+
+private:
+    ToolSchema schema_;
+    F function_;
+};
+
+template <AsyncToolCallable F> class AsyncToolAdapter final : public Tool
+{
+public:
+    AsyncToolAdapter(ToolSchema schema, F function)
+        : schema_(std::move(schema)), function_(std::move(function))
+    {
+        validateToolSchema(schema_);
+    }
+
+    const ToolSchema& schema() const override
+    {
+        return schema_;
+    }
+
+    mw::Task<mw::E<ToolResult>> invoke(nlohmann::json arguments,
+                                       ToolContext context) override
+    {
+        co_return co_await function_(std::move(arguments), std::move(context));
+    }
+
+private:
+    ToolSchema schema_;
+    F function_;
+};
+
+} // namespace detail
+
+/// Adapt a synchronous callable so it runs on the runtime's worker pool.
+template <SyncToolCallable F>
+std::unique_ptr<Tool> makeTool(ToolSchema schema, F function)
+{
+    return std::make_unique<detail::SyncToolAdapter<F>>(std::move(schema),
+                                                        std::move(function));
+}
+
+/// Adapt an asynchronous callable without moving it to a worker thread.
+template <AsyncToolCallable F>
+std::unique_ptr<Tool> makeTool(ToolSchema schema, F function)
+{
+    return std::make_unique<detail::AsyncToolAdapter<F>>(std::move(schema),
+                                                         std::move(function));
+}
+
+/// Owns named tools and their options in deterministic insertion order.
+class ToolRegistry
+{
+public:
+    /// Add a tool unless its name is already registered.
+    bool add(std::unique_ptr<Tool> tool, ToolOptions options = {});
+
+    /// Add a tool or replace the entry already registered under its name.
+    void replace(std::unique_ptr<Tool> tool, ToolOptions options = {});
+
+    /// Find a registered tool by name, or return null when it is absent.
+    Tool* find(std::string_view name);
+
+    /// Return registered options, or default options when the tool is absent.
+    ToolOptions options(std::string_view name) const;
+
+    /// Return schemas in deterministic insertion order.
+    std::vector<ToolSchema> schemas() const;
+
+    /// Return the number of registered tools.
+    std::size_t size() const;
+
+private:
+    struct Entry
+    {
+        std::unique_ptr<Tool> tool;
+        ToolOptions options;
+    };
+
+    std::unordered_map<std::string, Entry> by_name_;
+    std::vector<std::string> insertion_order_;
+};
+
+} // namespace agent_smith
diff --git a/src/llm_client.cpp b/src/llm_client.cpp
index 29d9154..0ec5eb7 100644
--- a/src/llm_client.cpp
+++ b/src/llm_client.cpp
@@ -100,6 +100,23 @@ mw::E<nlohmann::json> buildRequestBody(const LlmRequest& request,
     {
         body["messages"].push_back(toOpenAIJson(message));
     }
+    if(!request.tools.empty())
+    {
+        body["tools"] = nlohmann::json::array();
+        for(const ToolSchema& tool : request.tools)
+        {
+            body["tools"].push_back({
+                {"type", "function"},
+                {"function",
+                 {
+                     {"name", tool.name},
+                     {"description", tool.description},
+                     {"parameters", tool.parameters},
+                 }},
+            });
+        }
+        body["tool_choice"] = "auto";
+    }
     if(sampling.temperature)
     {
         body["temperature"] = *sampling.temperature;
diff --git a/src/tool.cpp b/src/tool.cpp
new file mode 100644
index 0000000..dd9427b
--- /dev/null
+++ b/src/tool.cpp
@@ -0,0 +1,87 @@
+#include "agent_smith/tool.hpp"
+
+namespace agent_smith
+{
+
+bool ToolRegistry::add(std::unique_ptr<Tool> tool, ToolOptions options)
+{
+    if(tool == nullptr)
+    {
+        return false;
+    }
+
+    const std::string name = tool->schema().name;
+    if(name.empty() || by_name_.contains(name))
+    {
+        return false;
+    }
+
+    const bool inserted = by_name_
+                              .emplace(name,
+                                       Entry{
+                                           .tool = std::move(tool),
+                                           .options = std::move(options),
+                                       })
+                              .second;
+    if(inserted)
+    {
+        insertion_order_.push_back(name);
+    }
+    return inserted;
+}
+
+void ToolRegistry::replace(std::unique_ptr<Tool> tool, ToolOptions options)
+{
+    if(tool == nullptr || tool->schema().name.empty())
+    {
+        return;
+    }
+
+    const std::string name = tool->schema().name;
+    const auto found = by_name_.find(name);
+    if(found == by_name_.end())
+    {
+        [[maybe_unused]] const bool added =
+            add(std::move(tool), std::move(options));
+        return;
+    }
+
+    found->second = Entry{
+        .tool = std::move(tool),
+        .options = std::move(options),
+    };
+}
+
+Tool* ToolRegistry::find(std::string_view name)
+{
+    const auto found = by_name_.find(std::string(name));
+    return found == by_name_.end() ? nullptr : found->second.tool.get();
+}
+
+ToolOptions ToolRegistry::options(std::string_view name) const
+{
+    const auto found = by_name_.find(std::string(name));
+    return found == by_name_.end() ? ToolOptions{} : found->second.options;
+}
+
+std::vector<ToolSchema> ToolRegistry::schemas() const
+{
+    std::vector<ToolSchema> result;
+    result.reserve(insertion_order_.size());
+    for(const std::string& name : insertion_order_)
+    {
+        const auto found = by_name_.find(name);
+        if(found != by_name_.end())
+        {
+            result.push_back(found->second.tool->schema());
+        }
+    }
+    return result;
+}
+
+std::size_t ToolRegistry::size() const
+{
+    return by_name_.size();
+}
+
+} // namespace agent_smith
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 9afb236..d9df8a4 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -3,6 +3,7 @@ add_executable(agent_smith_test_skeleton
     test_message.cpp
     test_sse_parser.cpp
     test_skeleton.cpp
+    test_tool.cpp
 )
 target_link_libraries(agent_smith_test_skeleton
     PRIVATE
diff --git a/tests/test_llm_client.cpp b/tests/test_llm_client.cpp
index 4a0a008..14ba5c3 100644
--- a/tests/test_llm_client.cpp
+++ b/tests/test_llm_client.cpp
@@ -210,6 +210,18 @@ TEST(LlmClientTest, SendsOpenAIRequestAndParsesAssistantResponse)
 
     LlmRequest request{
         .messages = {SystemMessage{"be concise"}, UserMessage{"hi"}},
+        .tools =
+            {
+                ToolSchema{
+                    .name = "weather",
+                    .description = "Look up the weather.",
+                    .parameters =
+                        {
+                            {"type", "object"},
+                            {"properties", {{"city", {{"type", "string"}}}}},
+                        },
+                },
+            },
         .sampling =
             SamplingParams{
                 .top_p = 0.9,
@@ -236,6 +248,22 @@ TEST(LlmClientTest, SendsOpenAIRequestAndParsesAssistantResponse)
     EXPECT_EQ(body.at("top_p"), 0.9);
     EXPECT_EQ(body.at("max_tokens"), 32);
     EXPECT_TRUE(body.at("provider_option"));
+    EXPECT_EQ(body.at("tool_choice"), "auto");
+    nlohmann::json expected_tools = nlohmann::json::array();
+    expected_tools.push_back({
+        {"type", "function"},
+        {"function",
+         {
+             {"name", "weather"},
+             {"description", "Look up the weather."},
+             {"parameters",
+              {
+                  {"type", "object"},
+                  {"properties", {{"city", {{"type", "string"}}}}},
+              }},
+         }},
+    });
+    EXPECT_EQ(body.at("tools"), expected_tools);
 }
 
 TEST(LlmClientTest, RejectsReservedExtraBeforeSendingRequest)
@@ -379,7 +407,9 @@ data: [DONE]
 
     ASSERT_TRUE(result.has_value());
     EXPECT_TRUE(first_hook_before_second_chunk);
-    EXPECT_TRUE(nlohmann::json::parse(captured.request_data).at("stream"));
+    const nlohmann::json body = nlohmann::json::parse(captured.request_data);
+    EXPECT_TRUE(body.at("stream"));
+    EXPECT_FALSE(body.contains("tools"));
     ASSERT_EQ(events.size(), 4U);
     const auto* token = std::get_if<StreamTokenDelta>(&events[0]);
     ASSERT_NE(token, nullptr);
diff --git a/tests/test_tool.cpp b/tests/test_tool.cpp
new file mode 100644
index 0000000..0b72373
--- /dev/null
+++ b/tests/test_tool.cpp
@@ -0,0 +1,246 @@
+#include <condition_variable>
+#include <coroutine>
+#include <exception>
+#include <mutex>
+#include <optional>
+#include <string>
+#include <thread>
+#include <utility>
+#include <vector>
+
+#include <agent_smith/tool.hpp>
+#include <gtest/gtest.h>
+
+namespace agent_smith
+{
+namespace
+{
+
+template <typename T> class SyncWaitTask
+{
+public:
+    struct promise_type
+    {
+        SyncWaitTask get_return_object()
+        {
+            return SyncWaitTask(
+                std::coroutine_handle<promise_type>::from_promise(*this));
+        }
+
+        std::suspend_always initial_suspend() noexcept
+        {
+            return {};
+        }
+
+        auto final_suspend() noexcept
+        {
+            struct FinalAwaiter
+            {
+                bool await_ready() noexcept
+                {
+                    return false;
+                }
+
+                void await_suspend(
+                    std::coroutine_handle<promise_type> handle) noexcept
+                {
+                    auto& promise = handle.promise();
+                    std::lock_guard lock(promise.mutex);
+                    promise.done = true;
+                    promise.condition.notify_one();
+                }
+
+                void await_resume() noexcept {}
+            };
+
+            return FinalAwaiter{};
+        }
+
+        template <typename U> void return_value(U&& value)
+        {
+            value_.emplace(std::forward<U>(value));
+        }
+
+        void unhandled_exception()
+        {
+            exception_ = std::current_exception();
+        }
+
+        std::mutex mutex;
+        std::condition_variable condition;
+        bool done = false;
+        std::optional<T> value_;
+        std::exception_ptr exception_;
+    };
+
+    explicit SyncWaitTask(std::coroutine_handle<promise_type> handle)
+        : handle_(handle)
+    {
+    }
+
+    SyncWaitTask(SyncWaitTask&& other) noexcept
+        : handle_(std::exchange(other.handle_, nullptr))
+    {
+    }
+
+    SyncWaitTask(const SyncWaitTask&) = delete;
+    SyncWaitTask& operator=(const SyncWaitTask&) = delete;
+
+    ~SyncWaitTask()
+    {
+        if(handle_ != nullptr)
+        {
+            handle_.destroy();
+        }
+    }
+
+    void start()
+    {
+        handle_.resume();
+    }
+
+    T get()
+    {
+        auto& promise = handle_.promise();
+        std::unique_lock lock(promise.mutex);
+        promise.condition.wait(lock, [&]() { return promise.done; });
+        lock.unlock();
+
+        if(promise.exception_ != nullptr)
+        {
+            std::rethrow_exception(promise.exception_);
+        }
+        return std::move(*promise.value_);
+    }
+
+private:
+    std::coroutine_handle<promise_type> handle_ = nullptr;
+};
+
+template <typename T> SyncWaitTask<T> makeWaiter(mw::Task<T> task)
+{
+    co_return co_await std::move(task);
+}
+
+template <typename T> T syncWait(mw::Task<T> task)
+{
+    auto waiter = makeWaiter(std::move(task));
+    waiter.start();
+    return waiter.get();
+}
+
+ToolSchema schema(std::string name)
+{
+    return ToolSchema{
+        .name = std::move(name),
+        .description = "test tool",
+        .parameters =
+            {
+                {"type", "object"},
+                {"properties", nlohmann::json::object()},
+            },
+    };
+}
+
+TEST(CancellationTest, TokensObserveSourceCancellation)
+{
+    CancellationSource source;
+    const CancellationToken token = source.token();
+
+    EXPECT_FALSE(token.cancelled());
+    source.cancel();
+    EXPECT_TRUE(token.cancelled());
+}
+
+TEST(ToolTest, SyncAdapterUsesRuntimeWorkerPool)
+{
+    Runtime runtime;
+    const std::thread::id caller_thread = std::this_thread::get_id();
+    std::thread::id invoked_thread;
+    auto tool = makeTool(schema("thread_id"),
+                         [&invoked_thread](nlohmann::json, ToolContext)
+                         {
+                             invoked_thread = std::this_thread::get_id();
+                             return "completed";
+                         });
+
+    auto result = syncWait(tool->invoke(nlohmann::json::object(),
+                                        ToolContext{.runtime = &runtime}));
+
+    ASSERT_TRUE(result.has_value());
+    EXPECT_EQ(result->content, "completed");
+    EXPECT_NE(invoked_thread, caller_thread);
+}
+
+mw::Task<mw::E<ToolResult>> asyncThreadTool(std::thread::id& invoked_thread,
+                                            nlohmann::json, ToolContext)
+{
+    invoked_thread = std::this_thread::get_id();
+    co_return ToolResult{
+        .content = "completed",
+    };
+}
+
+TEST(ToolTest, AsyncAdapterAwaitsCallableDirectly)
+{
+    Runtime runtime;
+    const std::thread::id caller_thread = std::this_thread::get_id();
+    std::thread::id invoked_thread;
+    auto tool = makeTool(
+        schema("async_thread_id"),
+        [&invoked_thread](nlohmann::json arguments, ToolContext context)
+        {
+            return asyncThreadTool(invoked_thread, std::move(arguments),
+                                   std::move(context));
+        });
+
+    auto result = syncWait(tool->invoke(nlohmann::json::object(),
+                                        ToolContext{.runtime = &runtime}));
+
+    ASSERT_TRUE(result.has_value());
+    EXPECT_EQ(result->content, "completed");
+    EXPECT_EQ(invoked_thread, caller_thread);
+}
+
+TEST(ToolTest, NormalizesStructuredAndFallibleResults)
+{
+    auto structured = normalizeToolResult(nlohmann::json{{"answer", 42}});
+    ASSERT_TRUE(structured.has_value());
+    EXPECT_EQ(structured->content, "{\"answer\":42}");
+    EXPECT_EQ(structured->structured, nlohmann::json({{"answer", 42}}));
+
+    const mw::E<ToolResult> failed =
+        std::unexpected(mw::runtimeError("tool failed"));
+    auto forwarded = normalizeToolResult(failed);
+    ASSERT_FALSE(forwarded.has_value());
+    EXPECT_EQ(forwarded.error().msg(), "tool failed");
+}
+
+TEST(ToolRegistryTest, PreservesInsertionOrderAndReplacesByName)
+{
+    ToolRegistry registry;
+    EXPECT_TRUE(registry.add(makeTool(
+        schema("first"), [](nlohmann::json, ToolContext) { return "first"; })));
+    EXPECT_TRUE(
+        registry.add(makeTool(schema("second"), [](nlohmann::json, ToolContext)
+                              { return "second"; }),
+                     ToolOptions{.timeout = std::chrono::seconds(3)}));
+    EXPECT_FALSE(
+        registry.add(makeTool(schema("first"), [](nlohmann::json, ToolContext)
+                              { return "collision"; })));
+
+    registry.replace(makeTool(schema("first"), [](nlohmann::json, ToolContext)
+                              { return "replaced"; }));
+
+    EXPECT_EQ(registry.size(), 2U);
+    ASSERT_NE(registry.find("first"), nullptr);
+    const auto schemas = registry.schemas();
+    ASSERT_EQ(schemas.size(), 2U);
+    EXPECT_EQ(schemas[0].name, "first");
+    EXPECT_EQ(schemas[1].name, "second");
+    ASSERT_TRUE(registry.options("second").timeout.has_value());
+    EXPECT_EQ(*registry.options("second").timeout, std::chrono::seconds(3));
+}
+
+} // namespace
+} // namespace agent_smith