BareGit

Rename internal namespace and centralize sync waiting

- Rename Agent Smith implementation symbols from detail to internal.
- Move synchronous task bridging into the public syncWait API.
- Update the weather-enabled demo, tests, and design examples.
Author: MetroWind <chris.corsair@gmail.com>
Date: Thu Sep 17 09:11:07 2026 -0700
Commit: 9b8d406dc815f207e2a3d3c4f1beea6e67026188

Changes

diff --git a/demo/main.cpp b/demo/main.cpp
index 3e55e93..21fc201 100644
--- a/demo/main.cpp
+++ b/demo/main.cpp
@@ -1,12 +1,263 @@
+#include <cstdlib>
 #include <iostream>
+#include <string>
+#include <string_view>
+#include <type_traits>
+#include <utility>
+#include <variant>
+#include <vector>
 
 #include <agent_smith/agent_smith.hpp>
 
-int main()
+namespace
 {
-    [[maybe_unused]] agent_smith::Runtime runtime;
-    [[maybe_unused]] agent_smith::Agent agent;
 
-    std::cout << "Agent Smith " << agent_smith::VERSION << '\n';
+std::string envOr(std::string_view name, std::string_view fallback)
+{
+    const char* value = std::getenv(std::string(name).c_str());
+    return value == nullptr ? std::string(fallback) : std::string(value);
+}
+
+void printUsage(std::ostream& output)
+{
+    output << "Usage: agent_smith_demo\n"
+           << "\n"
+           << "Environment variables:\n"
+           << "  LLAMA_SERVER_URL  Provider origin "
+              "(default: http://127.0.0.1:8080)\n"
+           << "  LLAMA_MODEL       Model name (default: local-model)\n"
+           << "  LLAMA_API_KEY     Optional bearer token\n"
+           << "\n"
+           << "The demo provides a tool for current weather by city.\n"
+           << "\n"
+           << "Commands: /help, /quit\n";
+}
+
+agent_smith::ToolSchema weatherSchema()
+{
+    return agent_smith::ToolSchema{
+        .name = "get_weather",
+        .description = "Get the current weather for a city.",
+        .parameters =
+            {
+                {"type", "object"},
+                {"properties", {{"city", {{"type", "string"}}}}},
+                {"required", {"city"}},
+                {"additionalProperties", false},
+            },
+    };
+}
+
+mw::Task<mw::E<agent_smith::ToolResult>>
+queryWeather(nlohmann::json arguments, agent_smith::ToolContext context)
+{
+    if(context.runtime == nullptr)
+    {
+        co_return std::unexpected(
+            mw::runtimeError("weather tool needs a runtime"));
+    }
+
+    const auto city = arguments.find("city");
+    if(city == arguments.end() || !city->is_string() || city->empty())
+    {
+        co_return std::unexpected(
+            mw::runtimeError("weather tool requires a non-empty city"));
+    }
+
+    const std::string city_name = *city;
+    const std::string url =
+        "https://wttr.in/" + mw::URL::encode(city_name) + "?format=3";
+    auto response =
+        co_await context.runtime->http().get(mw::HTTPRequest(url));
+    if(!response)
+    {
+        co_return std::unexpected(response.error());
+    }
+    if(response->status != 200)
+    {
+        co_return std::unexpected(
+            mw::runtimeError("weather service returned HTTP " +
+                             std::to_string(response->status)));
+    }
+
+    co_return agent_smith::ToolResult{
+        .content = std::string(response->payloadAsStr()),
+    };
+}
+
+void appendToolResults(std::vector<agent_smith::Message>& messages,
+                       agent_smith::ToolRegistry& tools,
+                       agent_smith::Runtime& runtime,
+                       const std::vector<agent_smith::ToolCall>& calls)
+{
+    for(const agent_smith::ToolCall& call : calls)
+    {
+        agent_smith::Tool* tool = tools.find(call.name);
+        if(tool == nullptr)
+        {
+            const std::string content = "unknown tool: " + call.name;
+            std::cout << "[tool error: " << content << "]\n";
+            messages.emplace_back(agent_smith::ToolMessage{
+                .tool_call_id = call.id,
+                .name = call.name,
+                .content = content,
+                .is_error = true,
+            });
+            continue;
+        }
+
+        auto result = syncWait(tool->invoke(
+            call.arguments,
+            agent_smith::ToolContext{
+                .call_id = call.id,
+                .runtime = &runtime,
+            }));
+        if(!result)
+        {
+            std::cout << "[tool error: " << result.error().msg() << "]\n";
+            messages.emplace_back(agent_smith::ToolMessage{
+                .tool_call_id = call.id,
+                .name = call.name,
+                .content = result.error().msg(),
+                .is_error = true,
+            });
+            continue;
+        }
+
+        std::cout << "[tool result: " << result->content << "]\n";
+        messages.emplace_back(agent_smith::ToolMessage{
+            .tool_call_id = call.id,
+            .name = call.name,
+            .content = result->content,
+            .is_error = result->is_error,
+        });
+    }
+}
+
+void printStreamEvent(const agent_smith::StreamEvent& event)
+{
+    std::visit(
+        [](const auto& value)
+        {
+            using Event = std::decay_t<decltype(value)>;
+            if constexpr(std::is_same_v<Event, agent_smith::StreamTokenDelta>)
+            {
+                std::cout << value.text << std::flush;
+            }
+            else if constexpr(std::is_same_v<
+                                  Event, agent_smith::StreamToolCallStarted>)
+            {
+                std::cout << "\n[tool call " << value.index << ": "
+                          << value.name << "]\n";
+            }
+            else if constexpr(std::is_same_v<
+                                  Event, agent_smith::StreamToolCallCompleted>)
+            {
+                std::cout << "[tool arguments " << value.index << ": "
+                          << value.arguments.dump() << "]\n";
+            }
+            else
+            {
+                std::cout << '\n';
+            }
+        },
+        event);
+}
+
+} // namespace
+
+int main(int argc, char* argv[])
+{
+    if(argc > 1 && std::string_view(argv[1]) == "--help")
+    {
+        printUsage(std::cout);
+        return 0;
+    }
+
+    const std::string endpoint_text =
+        envOr("LLAMA_SERVER_URL", "http://127.0.0.1:8080");
+    auto endpoint = mw::URL::fromStr(endpoint_text);
+    if(!endpoint)
+    {
+        std::cerr << "Invalid LLAMA_SERVER_URL: " << endpoint.error().msg()
+                  << '\n';
+        return 2;
+    }
+
+    agent_smith::LlmClientConfig config{
+        .endpoint = std::move(*endpoint),
+        .model = envOr("LLAMA_MODEL", "local-model"),
+    };
+    if(const char* api_key = std::getenv("LLAMA_API_KEY"); api_key != nullptr)
+    {
+        config.api_key = api_key;
+    }
+
+    agent_smith::Runtime runtime;
+    agent_smith::LlmClient client(runtime, std::move(config));
+    agent_smith::ToolRegistry tools;
+    tools.add(agent_smith::makeTool(weatherSchema(), queryWeather));
+    std::vector<agent_smith::Message> messages;
+    std::cout << "Agent Smith " << agent_smith::VERSION
+              << " provider test CLI. Enter /help for commands.\n";
+
+    std::string prompt;
+    while(std::cout << "> " << std::flush, std::getline(std::cin, prompt))
+    {
+        if(prompt == "/quit")
+        {
+            break;
+        }
+        if(prompt == "/help")
+        {
+            printUsage(std::cout);
+            continue;
+        }
+        if(prompt.empty())
+        {
+            continue;
+        }
+
+        const std::size_t message_count = messages.size();
+        messages.emplace_back(agent_smith::UserMessage{.content = prompt});
+        bool request_failed = false;
+        for(std::size_t iteration = 0; iteration < 4; ++iteration)
+        {
+            auto result = syncWait(client.completeStream(
+                {
+                    .messages = messages,
+                    .tools = tools.schemas(),
+                },
+                printStreamEvent));
+            if(!result)
+            {
+                std::cerr << "\nRequest failed: " << result.error().msg()
+                          << '\n';
+                request_failed = true;
+                break;
+            }
+
+            messages.emplace_back(std::move(result->message));
+            const auto& assistant =
+                std::get<agent_smith::AssistantMessage>(messages.back());
+            if(assistant.usage)
+            {
+                std::cout << "[usage: prompt=" << assistant.usage->prompt
+                          << ", completion=" << assistant.usage->completion
+                          << ", total=" << assistant.usage->total << "]\n";
+            }
+            if(assistant.tool_calls.empty())
+            {
+                break;
+            }
+
+            appendToolResults(messages, tools, runtime, assistant.tool_calls);
+        }
+        if(request_failed)
+        {
+            messages.resize(message_count);
+        }
+    }
+
     return 0;
 }
diff --git a/designs/design-0-framework.md b/designs/design-0-framework.md
index 74769ca..956968a 100644
--- a/designs/design-0-framework.md
+++ b/designs/design-0-framework.md
@@ -197,7 +197,7 @@ Agent Smith is a single static (or shared) library `libagent_smith`
 plus a small CLI demo binary `agent_smith_demo`. The library exposes
 one public include directory (`include/agent_smith/`) and a single
 namespace `agent_smith`. Internal helpers live in
-`agent_smith::detail` and are not part of the public API.
+`agent_smith::internal` and are not part of the public API.
 
 The library is composed of nine subsystems, each owning one
 sub-namespace and one or two header files:
@@ -393,7 +393,7 @@ public:
     // Run non-HTTP blocking work on the internal worker pool and return
     // an awaitable result. Do not use for HTTP.
     template <typename F>
-    mw::Task<detail::ExpectedResult<std::invoke_result_t<F>>>
+    mw::Task<internal::ExpectedResult<std::invoke_result_t<F>>>
         runOnWorker(F&& f);
 
     // Coroutine-friendly timer used for retry backoff and internal
@@ -403,7 +403,7 @@ public:
 private:
     RuntimeConfig cfg_;
     mw::HTTPSessionAsync http_;
-    detail::BlockingPool blocking_pool_;
+    internal::BlockingPool blocking_pool_;
 };
 
 } // namespace agent_smith
@@ -411,14 +411,14 @@ private:
 
 ### 5.3 Blocking runner
 
-`detail::BlockingPool` is a private utility implemented with
+`internal::BlockingPool` is a private utility implemented with
 `std::jthread`, `std::mutex`, `std::condition_variable`, and a FIFO work
 queue. Subsystems do not access it directly; they call
 `Runtime::runOnWorker`:
 
 ```cpp
 template <typename F>
-mw::Task<detail::ExpectedResult<std::invoke_result_t<F>>>
+mw::Task<internal::ExpectedResult<std::invoke_result_t<F>>>
 Runtime::runOnWorker(F&& f);
 ```
 
@@ -427,7 +427,7 @@ returns an `mw::E<T>` directly or a plain value wrapped by the
 adapter. The first implementation may support only the exact shapes
 needed by synchronous tools and stdio transport.
 
-`detail::ExpectedResult<T>` normalizes the return type:
+`internal::ExpectedResult<T>` normalizes the return type:
 
 ```cpp
 T        -> mw::E<T>
@@ -2077,7 +2077,7 @@ Hook failures are not converted into agent, tool, MCP, or LLM failures.
 ### 16.4 Redaction
 
 Before any header dictionary or body string is logged, run it
-through `agent_smith::detail::redact`:
+through `agent_smith::internal::redact`:
 
 * Replace any value of header keys equal (case-insensitive) to
   `Authorization`, `X-Api-Key`, `Api-Key`, `Cookie`, or
diff --git a/include/agent_smith/agent_smith.hpp b/include/agent_smith/agent_smith.hpp
index d1f3c2d..76b7701 100644
--- a/include/agent_smith/agent_smith.hpp
+++ b/include/agent_smith/agent_smith.hpp
@@ -7,5 +7,6 @@
 #include "message.hpp"
 #include "runtime.hpp"
 #include "stream_event.hpp"
+#include "sync_wait.hpp"
 #include "tool.hpp"
 #include "version.hpp"
diff --git a/include/agent_smith/detail/async_queue.hpp b/include/agent_smith/detail/async_queue.hpp
index b888371..53972e6 100644
--- a/include/agent_smith/detail/async_queue.hpp
+++ b/include/agent_smith/detail/async_queue.hpp
@@ -9,7 +9,7 @@
 
 #include <mw/task.hpp>
 
-namespace agent_smith::detail
+namespace agent_smith::internal
 {
 
 /// A closeable queue that resumes consumers when another thread pushes data.
@@ -143,4 +143,4 @@ private:
     bool closed_ = false;
 };
 
-} // namespace agent_smith::detail
+} // namespace agent_smith::internal
diff --git a/include/agent_smith/detail/blocking_pool.hpp b/include/agent_smith/detail/blocking_pool.hpp
index 64b2e9a..2a63a03 100644
--- a/include/agent_smith/detail/blocking_pool.hpp
+++ b/include/agent_smith/detail/blocking_pool.hpp
@@ -18,7 +18,7 @@
 
 #include <mw/error.hpp>
 
-namespace agent_smith::detail
+namespace agent_smith::internal
 {
 
 template <typename T> struct ExpectedResultType
@@ -215,4 +215,4 @@ template <typename F> auto BlockingPool::run(F&& function)
     return BlockingAwaiter<Result, Function>(std::move(work));
 }
 
-} // namespace agent_smith::detail
+} // namespace agent_smith::internal
diff --git a/include/agent_smith/detail/gather_all.hpp b/include/agent_smith/detail/gather_all.hpp
index edcfced..83a8b78 100644
--- a/include/agent_smith/detail/gather_all.hpp
+++ b/include/agent_smith/detail/gather_all.hpp
@@ -7,7 +7,7 @@
 
 #include "started_task.hpp"
 
-namespace agent_smith::detail
+namespace agent_smith::internal
 {
 
 /// Await started tasks in input order without changing their concurrency.
@@ -39,4 +39,4 @@ mw::Task<std::vector<mw::E<T>>> gatherAll(std::vector<mw::Task<mw::E<T>>> tasks)
     co_return co_await gatherAllStarted(std::move(started));
 }
 
-} // namespace agent_smith::detail
+} // namespace agent_smith::internal
diff --git a/include/agent_smith/detail/sse_parser.hpp b/include/agent_smith/detail/sse_parser.hpp
index 0423d41..e30e52b 100644
--- a/include/agent_smith/detail/sse_parser.hpp
+++ b/include/agent_smith/detail/sse_parser.hpp
@@ -4,7 +4,7 @@
 #include <string_view>
 #include <vector>
 
-namespace agent_smith::detail
+namespace agent_smith::internal
 {
 
 /// Incrementally extracts SSE data payloads from arbitrary byte chunks.
@@ -22,4 +22,4 @@ private:
     bool finished_ = false;
 };
 
-} // namespace agent_smith::detail
+} // namespace agent_smith::internal
diff --git a/include/agent_smith/detail/started_task.hpp b/include/agent_smith/detail/started_task.hpp
index d19fe8e..cf98a77 100644
--- a/include/agent_smith/detail/started_task.hpp
+++ b/include/agent_smith/detail/started_task.hpp
@@ -10,7 +10,7 @@
 #include <mw/error.hpp>
 #include <mw/task.hpp>
 
-namespace agent_smith::detail
+namespace agent_smith::internal
 {
 
 /// An internal eager wrapper around one mw::Task<mw::E<T>> operation.
@@ -180,4 +180,4 @@ private:
     std::coroutine_handle<promise_type> handle_ = nullptr;
 };
 
-} // namespace agent_smith::detail
+} // namespace agent_smith::internal
diff --git a/include/agent_smith/runtime.hpp b/include/agent_smith/runtime.hpp
index 479bf32..b687f8c 100644
--- a/include/agent_smith/runtime.hpp
+++ b/include/agent_smith/runtime.hpp
@@ -9,7 +9,7 @@
 
 #include "detail/blocking_pool.hpp"
 
-namespace agent_smith::detail
+namespace agent_smith::internal
 {
 class TimerService;
 }
@@ -51,7 +51,7 @@ public:
     /// Run non-HTTP work on the internal worker pool.
     template <typename F>
     auto runOnWorker(F&& function)
-        -> mw::Task<detail::ExpectedResult<std::invoke_result_t<F&>>>;
+        -> mw::Task<internal::ExpectedResult<std::invoke_result_t<F&>>>;
 
     /// Suspend without blocking the caller's coroutine thread.
     mw::Task<mw::E<void>> sleepFor(std::chrono::milliseconds duration);
@@ -59,13 +59,13 @@ public:
 private:
     RuntimeConfig cfg_;
     std::unique_ptr<mw::HTTPSessionAsync> http_;
-    std::unique_ptr<detail::BlockingPool> blocking_pool_;
-    std::unique_ptr<detail::TimerService> timer_service_;
+    std::unique_ptr<internal::BlockingPool> blocking_pool_;
+    std::unique_ptr<internal::TimerService> timer_service_;
 };
 
 template <typename F>
 auto Runtime::runOnWorker(F&& function)
-    -> mw::Task<detail::ExpectedResult<std::invoke_result_t<F&>>>
+    -> mw::Task<internal::ExpectedResult<std::invoke_result_t<F&>>>
 {
     co_return co_await blocking_pool_->run(std::forward<F>(function));
 }
diff --git a/include/agent_smith/sync_wait.hpp b/include/agent_smith/sync_wait.hpp
new file mode 100644
index 0000000..e250c82
--- /dev/null
+++ b/include/agent_smith/sync_wait.hpp
@@ -0,0 +1,136 @@
+#pragma once
+
+#include <condition_variable>
+#include <coroutine>
+#include <exception>
+#include <mutex>
+#include <optional>
+#include <utility>
+
+#include <mw/task.hpp>
+
+namespace agent_smith
+{
+namespace internal
+{
+
+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> makeSyncWaiter(mw::Task<T> task)
+{
+    co_return co_await std::move(task);
+}
+
+} // namespace internal
+
+/// Start a task and block this thread until its result is available.
+///
+/// Use only at a synchronous application boundary or in tests. Do not call
+/// this from a thread that must run work required by the task to complete.
+template <typename T> T syncWait(mw::Task<T> task)
+{
+    auto waiter = internal::makeSyncWaiter(std::move(task));
+    waiter.start();
+    return waiter.get();
+}
+
+} // namespace agent_smith
diff --git a/include/agent_smith/tool.hpp b/include/agent_smith/tool.hpp
index cf4d50d..1c79a05 100644
--- a/include/agent_smith/tool.hpp
+++ b/include/agent_smith/tool.hpp
@@ -142,7 +142,7 @@ concept AsyncToolCallable =
         } -> std::same_as<mw::Task<mw::E<ToolResult>>>;
     };
 
-namespace detail
+namespace internal
 {
 
 inline void validateToolSchema(const ToolSchema& schema)
@@ -234,22 +234,22 @@ private:
     F function_;
 };
 
-} // namespace detail
+} // namespace internal
 
 /// 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));
+    return std::make_unique<internal::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));
+    return std::make_unique<internal::AsyncToolAdapter<F>>(std::move(schema),
+                                                           std::move(function));
 }
 
 /// Owns named tools and their options in deterministic insertion order.
diff --git a/src/detail/blocking_pool.cpp b/src/detail/blocking_pool.cpp
index 71209da..345468c 100644
--- a/src/detail/blocking_pool.cpp
+++ b/src/detail/blocking_pool.cpp
@@ -2,7 +2,7 @@
 
 #include <algorithm>
 
-namespace agent_smith::detail
+namespace agent_smith::internal
 {
 
 BlockingPool::BlockingPool(std::size_t size)
@@ -75,4 +75,4 @@ void BlockingPool::worker(std::stop_token stop)
     }
 }
 
-} // namespace agent_smith::detail
+} // namespace agent_smith::internal
diff --git a/src/detail/timer_service.cpp b/src/detail/timer_service.cpp
index cfadf11..021f166 100644
--- a/src/detail/timer_service.cpp
+++ b/src/detail/timer_service.cpp
@@ -1,6 +1,6 @@
 #include "timer_service.hpp"
 
-namespace agent_smith::detail
+namespace agent_smith::internal
 {
 
 TimerService::TimerService()
@@ -87,4 +87,4 @@ void TimerService::worker(std::stop_token stop)
     }
 }
 
-} // namespace agent_smith::detail
+} // namespace agent_smith::internal
diff --git a/src/detail/timer_service.hpp b/src/detail/timer_service.hpp
index 96f847b..12bd2c2 100644
--- a/src/detail/timer_service.hpp
+++ b/src/detail/timer_service.hpp
@@ -10,7 +10,7 @@
 #include <thread>
 #include <vector>
 
-namespace agent_smith::detail
+namespace agent_smith::internal
 {
 
 class TimerService
@@ -71,4 +71,4 @@ private:
     std::jthread worker_;
 };
 
-} // namespace agent_smith::detail
+} // namespace agent_smith::internal
diff --git a/src/llm_client.cpp b/src/llm_client.cpp
index 0ec5eb7..739ff83 100644
--- a/src/llm_client.cpp
+++ b/src/llm_client.cpp
@@ -517,9 +517,9 @@ private:
 
 mw::Task<mw::E<mw::HTTPResponse>>
 streamRequest(mw::HTTPSessionAsyncInterface& http, mw::HTTPRequest request,
-              detail::SseFrameParser& frame_parser,
+              internal::SseFrameParser& frame_parser,
               StreamDeltaParser& delta_parser,
-              detail::AsyncQueue<StreamEvent>& queue)
+              internal::AsyncQueue<StreamEvent>& queue)
 {
     std::optional<mw::Error> callback_error;
     auto response = co_await http.postStream(
@@ -787,10 +787,10 @@ LlmClient::completeStream(LlmRequest request,
     LlmClientConfig request_config = config_;
     request_config.endpoint = std::move(*endpoint);
 
-    detail::SseFrameParser frame_parser;
+    internal::SseFrameParser frame_parser;
     StreamDeltaParser delta_parser;
-    detail::AsyncQueue<StreamEvent> queue;
-    auto request_task = detail::StartedTask<mw::HTTPResponse>::start(
+    internal::AsyncQueue<StreamEvent> queue;
+    auto request_task = internal::StartedTask<mw::HTTPResponse>::start(
         streamRequest(*http_, buildHttpRequest(*body, request_config),
                       frame_parser, delta_parser, queue));
 
diff --git a/src/runtime.cpp b/src/runtime.cpp
index 3f2dd0a..4310ca4 100644
--- a/src/runtime.cpp
+++ b/src/runtime.cpp
@@ -7,9 +7,9 @@ namespace agent_smith
 
 Runtime::Runtime(RuntimeConfig cfg)
     : cfg_(cfg), http_(std::make_unique<mw::HTTPSessionAsync>()),
-      blocking_pool_(std::make_unique<detail::BlockingPool>(
+      blocking_pool_(std::make_unique<internal::BlockingPool>(
           cfg_.blocking_pool_size)),
-      timer_service_(std::make_unique<detail::TimerService>())
+      timer_service_(std::make_unique<internal::TimerService>())
 {
     auto& http = static_cast<mw::HTTPSessionAsyncInterface&>(*http_);
     [[maybe_unused]] auto connection_timeout =
diff --git a/src/sse_parser.cpp b/src/sse_parser.cpp
index 405ab99..385960d 100644
--- a/src/sse_parser.cpp
+++ b/src/sse_parser.cpp
@@ -4,7 +4,7 @@
 #include <string_view>
 #include <utility>
 
-namespace agent_smith::detail
+namespace agent_smith::internal
 {
 namespace
 {
@@ -130,4 +130,4 @@ bool SseFrameParser::finished() const noexcept
     return finished_;
 }
 
-} // namespace agent_smith::detail
+} // namespace agent_smith::internal
diff --git a/tests/test_llm_client.cpp b/tests/test_llm_client.cpp
index 14ba5c3..f4a8112 100644
--- a/tests/test_llm_client.cpp
+++ b/tests/test_llm_client.cpp
@@ -1,11 +1,6 @@
 #include <atomic>
 #include <chrono>
-#include <condition_variable>
-#include <coroutine>
 #include <cstddef>
-#include <exception>
-#include <mutex>
-#include <optional>
 #include <span>
 #include <string>
 #include <string_view>
@@ -13,6 +8,7 @@
 #include <vector>
 
 #include <agent_smith/llm_client.hpp>
+#include <agent_smith/sync_wait.hpp>
 #include <gmock/gmock.h>
 #include <gtest/gtest.h>
 #include <mw/http_client_async_mock.hpp>
@@ -26,119 +22,6 @@ using ::testing::_;
 using ::testing::InSequence;
 using ::testing::Invoke;
 
-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();
-}
-
 mw::URL testEndpoint()
 {
     auto endpoint = mw::URL::fromStr("https://llm.example/api");
diff --git a/tests/test_skeleton.cpp b/tests/test_skeleton.cpp
index 13b5bf3..c9583f3 100644
--- a/tests/test_skeleton.cpp
+++ b/tests/test_skeleton.cpp
@@ -1,10 +1,5 @@
 #include <atomic>
 #include <chrono>
-#include <condition_variable>
-#include <coroutine>
-#include <exception>
-#include <mutex>
-#include <optional>
 #include <stdexcept>
 #include <thread>
 #include <utility>
@@ -21,122 +16,6 @@ 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();
-}
-
 mw::Task<mw::E<int>> markAndSleep(Runtime& runtime, std::atomic<bool>& started,
                                   int value, std::chrono::milliseconds duration)
 {
@@ -150,7 +29,7 @@ mw::Task<mw::E<int>> markAndSleep(Runtime& runtime, std::atomic<bool>& started,
     co_return value;
 }
 
-mw::Task<mw::E<int>> awaitStarted(detail::StartedTask<int> task)
+mw::Task<mw::E<int>> awaitStarted(internal::StartedTask<int> task)
 {
     co_return co_await std::move(task);
 }
@@ -221,7 +100,7 @@ TEST(StartedTaskTest, StartsImmediatelyAndCanBeAwaited)
     Runtime runtime;
     std::atomic<bool> started = false;
 
-    auto task = detail::StartedTask<int>::start(
+    auto task = internal::StartedTask<int>::start(
         markAndSleep(runtime, started, 7, std::chrono::milliseconds(1)));
 
     EXPECT_TRUE(started.load());
@@ -232,7 +111,7 @@ TEST(StartedTaskTest, StartsImmediatelyAndCanBeAwaited)
 
 TEST(AsyncQueueTest, DeliversItemsAcrossThreadsAndCloses)
 {
-    detail::AsyncQueue<int> queue;
+    internal::AsyncQueue<int> queue;
     std::thread producer(
         [&queue]()
         {
@@ -252,7 +131,7 @@ TEST(AsyncQueueTest, DeliversItemsAcrossThreadsAndCloses)
 
 TEST(AsyncQueueTest, DrainsBufferedItemsBeforeClosure)
 {
-    detail::AsyncQueue<int> queue;
+    internal::AsyncQueue<int> queue;
     queue.push(3);
     queue.push(5);
     queue.close();
@@ -279,7 +158,7 @@ TEST(GatherAllTest, StartsAllTasksAndPreservesInputOrder)
     tasks.push_back(
         markAndSleep(runtime, second_started, 2, std::chrono::milliseconds(1)));
 
-    auto results = syncWait(detail::gatherAll(std::move(tasks)));
+    auto results = syncWait(internal::gatherAll(std::move(tasks)));
 
     ASSERT_EQ(results.size(), 2U);
     ASSERT_TRUE(results[0].has_value());
diff --git a/tests/test_sse_parser.cpp b/tests/test_sse_parser.cpp
index 3adc718..cb3742d 100644
--- a/tests/test_sse_parser.cpp
+++ b/tests/test_sse_parser.cpp
@@ -4,7 +4,7 @@
 #include <agent_smith/detail/sse_parser.hpp>
 #include <gtest/gtest.h>
 
-namespace agent_smith::detail
+namespace agent_smith::internal
 {
 namespace
 {
@@ -95,4 +95,4 @@ TEST(SseFrameParserTest, ProducesIdenticalPayloadsAtEveryByteSplit)
 }
 
 } // namespace
-} // namespace agent_smith::detail
+} // namespace agent_smith::internal
diff --git a/tests/test_tool.cpp b/tests/test_tool.cpp
index 0b72373..407677a 100644
--- a/tests/test_tool.cpp
+++ b/tests/test_tool.cpp
@@ -1,13 +1,9 @@
-#include <condition_variable>
-#include <coroutine>
-#include <exception>
-#include <mutex>
-#include <optional>
 #include <string>
 #include <thread>
 #include <utility>
 #include <vector>
 
+#include <agent_smith/sync_wait.hpp>
 #include <agent_smith/tool.hpp>
 #include <gtest/gtest.h>
 
@@ -16,119 +12,6 @@ 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{