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