BareGit

Implement the agent conversation loop

- Connect memory, streaming and non-streaming LLM requests, and tool dispatch.
- Add observer hooks, execution limits, structured completion signals, and
  cooperative cancellation for tools and streamed HTTP responses.
- Document loop behavior and test conversation flow, failures, and stops.
Author: MetroWind <chris.corsair@gmail.com>
Date: Mon Sep 21 16:20:26 2026 -0700
Commit: 2938b53e09d6dff773b2930eef4764c1d143d23d

Changes

diff --git a/designs/design-0-framework.md b/designs/design-0-framework.md
index d529695..b1f374c 100644
--- a/designs/design-0-framework.md
+++ b/designs/design-0-framework.md
@@ -1976,6 +1976,16 @@ public:
 
 ### 15.2 The loop, step by step
 
+Milestone 10 implements native tools and the memory/LLM loop; skill and MCP
+registration arrive in their respective milestones. Null constructor memory
+selects `FullMemory`. `Agent::cancel()` permanently cancels the agent and its
+shared tree. Tool tokens observe tree cancellation, and streaming requests
+abort at the next received chunk. Loop deadlines are checked between awaited
+operations, without preempting an in-flight request. Iteration and token
+counts reset for each send; the shared tree counter persists with its state.
+When several tools return a successful structured `_done` result, the first
+in call order supplies the final message, after all tool results are recorded.
+
 ```
 1. Normalize the input into an `AgentRequest` named `agent_req`. The
    `send(std::string)` overload creates `{UserMessage{user}, {}, {}}`.
diff --git a/include/agent_smith/agent.hpp b/include/agent_smith/agent.hpp
index 8ed8d91..8a3cc38 100644
--- a/include/agent_smith/agent.hpp
+++ b/include/agent_smith/agent.hpp
@@ -2,25 +2,104 @@
 
 #include <chrono>
 #include <cstddef>
+#include <atomic>
+#include <functional>
+#include <memory>
+#include <optional>
+
+#include "llm_client.hpp"
+#include "memory.hpp"
 
 namespace agent_smith
 {
 
-/// Agent execution limits used by tool dispatch.
+/// Shared cancellation and iteration accounting for an agent tree.
+struct AgentTreeState
+{
+    /// Number of LLM iterations started across the tree.
+    std::atomic<int> iterations{0};
+    /// Cooperative cancellation shared by the tree.
+    CancellationSource cancel;
+};
+
+/// Agent execution limits and default request behavior.
 struct AgentConfig
 {
+    /// Maximum LLM requests per send.
+    int max_iterations = 16;
+    /// Wall-clock limit per send, checked between awaited operations.
+    std::optional<std::chrono::seconds> deadline = std::nullopt;
+    /// Cap on reported total token usage per send; missing usage is uncounted.
+    std::optional<std::uint64_t> max_total_tokens = std::nullopt;
     /// Default per-call budget when a tool has no override.
     std::chrono::milliseconds default_tool_timeout{30'000};
     /// Maximum retained content bytes, excluding the truncation marker.
     std::size_t tool_result_max_bytes = 64 * 1024;
+    /// Whether send uses streaming unless the request overrides it.
+    bool stream_default = true;
+    /// Maximum iterations across the shared tree state.
+    int tree_max_iterations = 50;
+    /// Shared tree state, or null to create a state owned by this agent.
+    std::shared_ptr<AgentTreeState> tree_state;
+};
+
+/// Synchronous observers. Exceptions are logged and ignored; do not block.
+struct AgentHooks
+{
+    /// Observe each assistant message appended to memory.
+    std::function<void(const Message&)> on_message;
+    /// Observe each requested tool call before dispatch.
+    std::function<void(const ToolCall&)> on_tool_call;
+    /// Observe each tool result after it is appended to memory.
+    std::function<void(const ToolMessage&)> on_tool_result;
+    /// Observe streamed events as they arrive.
+    std::function<void(const StreamEvent&)> on_stream_event;
+    /// Observe a framework error before send returns it.
+    std::function<void(const mw::Error&)> on_error;
+};
+
+/// One user turn with optional sampling and streaming overrides.
+struct AgentRequest
+{
+    /// User message to append before running the loop.
+    UserMessage message;
+    /// Per-turn LLM sampling overrides.
+    SamplingParams sampling;
+    /// Streaming override; absent uses AgentConfig::stream_default.
+    std::optional<bool> stream;
 };
 
-/// Placeholder for the agent loop, implemented in later milestones.
+/// Drives conversation, LLM requests, and native tool execution.
+/// Only one coroutine may use an agent at a time. Dependencies must outlive it.
 class Agent
 {
 public:
-    /// Construct an empty agent.
-    Agent() = default;
+    /// Construct an agent; null memory creates a FullMemory instance.
+    Agent(Runtime& runtime, LlmClient& client, std::shared_ptr<Memory> memory,
+          AgentConfig config = {});
+    /// Replace the base system prompt used for subsequent requests.
+    void setSystemPrompt(std::string prompt);
+    /// Register a native tool; duplicate or empty names return an error.
+    mw::E<void> addTool(std::unique_ptr<Tool> tool, ToolOptions options = {});
+    /// Access synchronous observers for this agent.
+    AgentHooks& hooks();
+    /// Cancel this agent and its shared tree; tools observe linked tokens and
+    /// streaming HTTP stops on the next chunk. Safe to call from any thread.
+    void cancel();
+    /// Append a user message and run until completion or a stop condition.
+    mw::Task<mw::E<AssistantMessage>> send(std::string user);
+    /// Run one turn with sampling and streaming overrides.
+    mw::Task<mw::E<AssistantMessage>> send(AgentRequest request);
+private:
+    mw::Task<mw::E<AssistantMessage>> run(AgentRequest request);
+    Runtime* runtime_;
+    LlmClient* client_;
+    std::shared_ptr<Memory> memory_;
+    AgentConfig config_;
+    AgentHooks hooks_;
+    ToolRegistry tools_;
+    SystemMessage system_;
+    CancellationSource cancel_;
 };
 
 } // namespace agent_smith
diff --git a/include/agent_smith/cancellation.hpp b/include/agent_smith/cancellation.hpp
index 5f5d23a..9a3ec5d 100644
--- a/include/agent_smith/cancellation.hpp
+++ b/include/agent_smith/cancellation.hpp
@@ -17,7 +17,8 @@ public:
     /// Return whether the associated cancellation source has been cancelled.
     bool cancelled() const noexcept
     {
-        return state_ != nullptr && state_->load();
+        return (state_ != nullptr && state_->load()) ||
+               (parent_ != nullptr && parent_->cancelled());
     }
 
 private:
@@ -29,6 +30,7 @@ private:
     }
 
     std::shared_ptr<std::atomic_bool> state_;
+    std::shared_ptr<const CancellationToken> parent_;
 };
 
 /// Owns and triggers a cancellation signal shared with its tokens.
@@ -38,10 +40,19 @@ public:
     /// Create a source with an initially non-cancelled token.
     CancellationSource() : state_(std::make_shared<std::atomic_bool>(false)) {}
 
+    /// Create a source whose tokens also observe a parent cancellation token.
+    explicit CancellationSource(CancellationToken parent)
+        : CancellationSource()
+    {
+        parent_ = std::make_shared<CancellationToken>(std::move(parent));
+    }
+
     /// Return a token that observes this source's cancellation state.
     CancellationToken token() const
     {
-        return CancellationToken(state_);
+        CancellationToken result(state_);
+        result.parent_ = parent_;
+        return result;
     }
 
     /// Request cooperative cancellation for every token from this source.
@@ -52,6 +63,7 @@ public:
 
 private:
     std::shared_ptr<std::atomic_bool> state_;
+    std::shared_ptr<const CancellationToken> parent_;
 };
 
 } // namespace agent_smith
diff --git a/include/agent_smith/detail/hook.hpp b/include/agent_smith/detail/hook.hpp
new file mode 100644
index 0000000..b768d7e
--- /dev/null
+++ b/include/agent_smith/detail/hook.hpp
@@ -0,0 +1,33 @@
+#pragma once
+
+#include <spdlog/spdlog.h>
+
+namespace agent_smith::internal
+{
+
+/// Invoke a best-effort observer without allowing exceptions to escape.
+template <typename F, typename T> void invokeHook(const F& hook, const T& value)
+{
+    if(!hook)
+    {
+        return;
+    }
+    try
+    {
+        hook(value);
+    }
+    catch(...)
+    {
+        // Do not log exception text: observers may include secrets in it.
+        try
+        {
+            if(auto logger = spdlog::get("agent_smith"))
+            {
+                logger->warn("agent observer threw; continuing execution");
+            }
+        }
+        catch(...) {}
+    }
+}
+
+} // namespace agent_smith::internal
diff --git a/include/agent_smith/detail/tool_dispatch.hpp b/include/agent_smith/detail/tool_dispatch.hpp
index 4b0c278..8aaa333 100644
--- a/include/agent_smith/detail/tool_dispatch.hpp
+++ b/include/agent_smith/detail/tool_dispatch.hpp
@@ -9,8 +9,10 @@ namespace agent_smith::internal
 
 /// Run every call concurrently and collect tool messages in input order.
 /// Timed-out calls retain their tool instance until execution completes.
+/// If supplied, final_message receives the first successful structured _done
+/// result in call order. It must outlive the returned task.
 mw::Task<mw::E<std::vector<ToolMessage>>> dispatchTools(
     Runtime& runtime, ToolRegistry& registry, std::vector<ToolCall> calls,
-    AgentConfig config = {});
+    AgentConfig config = {}, std::optional<std::string>* final_message = nullptr);
 
 } // namespace agent_smith::internal
diff --git a/include/agent_smith/error.hpp b/include/agent_smith/error.hpp
index 6540139..0661cb3 100644
--- a/include/agent_smith/error.hpp
+++ b/include/agent_smith/error.hpp
@@ -39,9 +39,24 @@ struct TimeoutError
     std::chrono::milliseconds budget;
 };
 
+/// Execution stopped because of cancellation or an agent limit.
+struct CancelledError
+{
+    /// Human-readable stop reason.
+    std::string msg;
+    /// Operation that was stopped.
+    std::string operation;
+};
+
 namespace error
 {
 
+/// Create a typed stop-condition error for an agent send.
+inline mw::Error cancel(std::string_view reason)
+{
+    return CancelledError{std::string(reason), "agent send"};
+}
+
 /// Create a typed parse error with an optional byte offset.
 inline mw::Error parse(std::string_view message, std::size_t offset = 0)
 {
diff --git a/include/agent_smith/llm_client.hpp b/include/agent_smith/llm_client.hpp
index 3ef05b4..d4f55cc 100644
--- a/include/agent_smith/llm_client.hpp
+++ b/include/agent_smith/llm_client.hpp
@@ -73,6 +73,8 @@ struct LlmRequest
 
     /// Sampling values that override the client's defaults.
     SamplingParams sampling;
+    /// Cooperative cancellation checked before requests and streamed chunks.
+    CancellationToken cancel;
 };
 
 /// The final assistant message returned by an LLM completion request.
diff --git a/include/agent_smith/tool.hpp b/include/agent_smith/tool.hpp
index a996dd9..c08f87a 100644
--- a/include/agent_smith/tool.hpp
+++ b/include/agent_smith/tool.hpp
@@ -203,6 +203,11 @@ public:
             [function = function_, arguments = std::move(arguments),
              context = std::move(context)]() mutable
         {
+            if(context.cancel.cancelled())
+            {
+                return mw::E<ToolResult>(std::unexpected(
+                    mw::runtimeError("tool cancelled before execution")));
+            }
             return normalizeToolResult(
                 function(std::move(arguments), std::move(context)));
         };
diff --git a/src/agent.cpp b/src/agent.cpp
index 8c5625d..5386c64 100644
--- a/src/agent.cpp
+++ b/src/agent.cpp
@@ -1 +1,177 @@
 #include "agent_smith/agent.hpp"
+
+#include <limits>
+#include <algorithm>
+
+#include "agent_smith/detail/hook.hpp"
+#include "agent_smith/detail/tool_dispatch.hpp"
+
+namespace agent_smith
+{
+
+Agent::Agent(Runtime& runtime, LlmClient& client,
+             std::shared_ptr<Memory> memory, AgentConfig config)
+    : runtime_(&runtime), client_(&client),
+      memory_(memory ? std::move(memory) : std::make_shared<FullMemory>()),
+      config_(std::move(config))
+{
+    if(!config_.tree_state)
+    {
+        config_.tree_state = std::make_shared<AgentTreeState>();
+    }
+}
+
+void Agent::setSystemPrompt(std::string prompt)
+{
+    system_.content = std::move(prompt);
+}
+
+mw::E<void> Agent::addTool(std::unique_ptr<Tool> tool, ToolOptions options)
+{
+    if(!tools_.add(std::move(tool), std::move(options)))
+    {
+        return std::unexpected(mw::runtimeError("invalid or duplicate tool"));
+    }
+    return {};
+}
+
+AgentHooks& Agent::hooks() { return hooks_; }
+void Agent::cancel()
+{
+    cancel_.cancel();
+    config_.tree_state->cancel.cancel();
+}
+
+mw::Task<mw::E<AssistantMessage>> Agent::send(std::string user)
+{
+    AgentRequest request;
+    request.message.content = std::move(user);
+    co_return co_await send(std::move(request));
+}
+
+mw::Task<mw::E<AssistantMessage>> Agent::send(AgentRequest request)
+{
+    auto result = co_await run(std::move(request));
+    if(!result)
+    {
+        internal::invokeHook(hooks_.on_error, result.error());
+    }
+    co_return result;
+}
+
+mw::Task<mw::E<AssistantMessage>> Agent::run(AgentRequest request)
+{
+    const auto started = std::chrono::steady_clock::now();
+    std::uint64_t total_tokens = 0;
+    auto stopped = [&]() -> std::optional<mw::Error>
+    {
+        if(cancel_.token().cancelled() ||
+           config_.tree_state->cancel.token().cancelled())
+        {
+            return error::cancel("agent cancelled");
+        }
+        if(config_.deadline &&
+           std::chrono::steady_clock::now() - started >= *config_.deadline)
+        {
+            return error::cancel("agent deadline exceeded");
+        }
+        if(config_.max_total_tokens && total_tokens > *config_.max_total_tokens)
+        {
+            return error::cancel("agent token cap exceeded");
+        }
+        return std::nullopt;
+    };
+    if(auto error = stopped())
+    {
+        co_return std::unexpected(*error);
+    }
+    memory_->append(std::move(request.message));
+    for(int iteration = 0; iteration < config_.max_iterations; ++iteration)
+    {
+        if(auto error = stopped())
+        {
+            co_return std::unexpected(*error);
+        }
+        if(config_.tree_state->iterations.fetch_add(1) >=
+           config_.tree_max_iterations)
+        {
+            co_return std::unexpected(error::cancel("agent tree iteration cap"));
+        }
+        auto history = co_await memory_->snapshot(system_);
+        if(!history)
+        {
+            co_return std::unexpected(history.error());
+        }
+        if(auto error = stopped())
+        {
+            co_return std::unexpected(*error);
+        }
+        LlmRequest llm_request{
+            .messages = std::move(*history),
+            .tools = tools_.schemas(),
+            .sampling = request.sampling,
+            .cancel = config_.tree_state->cancel.token(),
+        };
+        auto response = request.stream.value_or(config_.stream_default)
+            ? co_await client_->completeStream(std::move(llm_request),
+                [this](const StreamEvent& event)
+                { internal::invokeHook(hooks_.on_stream_event, event); })
+            : co_await client_->complete(std::move(llm_request));
+        if(!response)
+        {
+            co_return std::unexpected(response.error());
+        }
+        auto& message = response->message;
+        if(message.usage)
+        {
+            const auto remaining =
+                std::numeric_limits<std::uint64_t>::max() - total_tokens;
+            total_tokens += std::min(remaining, message.usage->total);
+        }
+        memory_->append(message);
+        internal::invokeHook(hooks_.on_message, Message(message));
+        if(auto error = stopped())
+        {
+            co_return std::unexpected(*error);
+        }
+        if(message.tool_calls.empty())
+        {
+            co_return std::move(message);
+        }
+        for(const auto& call : message.tool_calls)
+        {
+            internal::invokeHook(hooks_.on_tool_call, call);
+        }
+        if(auto error = stopped())
+        {
+            co_return std::unexpected(*error);
+        }
+        std::optional<std::string> final_message;
+        auto results = co_await internal::dispatchTools(
+            *runtime_, tools_, message.tool_calls, config_, &final_message);
+        if(!results)
+        {
+            co_return std::unexpected(results.error());
+        }
+        for(const auto& result : *results)
+        {
+            memory_->append(result);
+            internal::invokeHook(hooks_.on_tool_result, result);
+        }
+        if(auto error = stopped())
+        {
+            co_return std::unexpected(*error);
+        }
+        if(final_message)
+        {
+            AssistantMessage final;
+            final.content = std::move(*final_message);
+            memory_->append(final);
+            internal::invokeHook(hooks_.on_message, Message(final));
+            co_return final;
+        }
+    }
+    co_return std::unexpected(error::cancel("agent iteration cap"));
+}
+
+} // namespace agent_smith
diff --git a/src/detail/tool_dispatch.cpp b/src/detail/tool_dispatch.cpp
index fd17f71..056a6ec 100644
--- a/src/detail/tool_dispatch.cpp
+++ b/src/detail/tool_dispatch.cpp
@@ -40,7 +40,7 @@ std::string errorContent(const std::string& message)
 
 mw::Task<mw::E<std::vector<ToolMessage>>> dispatchTools(
     Runtime& runtime, ToolRegistry& registry, std::vector<ToolCall> calls,
-    AgentConfig config)
+    AgentConfig config, std::optional<std::string>* final_message)
 {
     std::vector<StartedTask<ToolResult>> started;
     started.reserve(calls.size());
@@ -55,7 +55,8 @@ mw::Task<mw::E<std::vector<ToolMessage>>> dispatchTools(
         }
         const auto budget = registry.options(call.name).timeout.value_or(
             config.default_tool_timeout);
-        CancellationSource cancel;
+        CancellationSource cancel(config.tree_state
+            ? config.tree_state->cancel.token() : CancellationToken{});
         ToolContext context{
             .cancel = cancel.token(),
             .budget = budget,
@@ -76,6 +77,16 @@ mw::Task<mw::E<std::vector<ToolMessage>>> dispatchTools(
     {
         auto& result = results[index];
         const bool is_error = !result || result->is_error;
+        if(!is_error && final_message != nullptr && !*final_message &&
+           result->structured.is_object())
+        {
+            const auto& data = result->structured;
+            if(data.contains("_done") && data["_done"] == true &&
+               data.contains("final_message") && data["final_message"].is_string())
+            {
+                *final_message = data["final_message"].get<std::string>();
+            }
+        }
         std::string content = !result ? errorContent(result.error().msg())
             : result->is_error ? errorContent(result->content)
                                : std::move(result->content);
diff --git a/src/llm_client.cpp b/src/llm_client.cpp
index 739ff83..131780a 100644
--- a/src/llm_client.cpp
+++ b/src/llm_client.cpp
@@ -519,13 +519,19 @@ mw::Task<mw::E<mw::HTTPResponse>>
 streamRequest(mw::HTTPSessionAsyncInterface& http, mw::HTTPRequest request,
               internal::SseFrameParser& frame_parser,
               StreamDeltaParser& delta_parser,
-              internal::AsyncQueue<StreamEvent>& queue)
+              internal::AsyncQueue<StreamEvent>& queue,
+              CancellationToken cancel)
 {
     std::optional<mw::Error> callback_error;
     auto response = co_await http.postStream(
         std::move(request),
         [&](std::span<const std::byte> chunk)
         {
+            if(cancel.cancelled())
+            {
+                callback_error = error::cancel("LLM stream cancelled");
+                return false;
+            }
             if(callback_error)
             {
                 return false;
@@ -712,6 +718,10 @@ LlmClient::LlmClient(Runtime& runtime, mw::HTTPSessionAsyncInterface& http,
 
 mw::Task<mw::E<LlmResponse>> LlmClient::complete(LlmRequest request)
 {
+    if(request.cancel.cancelled())
+    {
+        co_return std::unexpected(error::cancel("LLM request cancelled"));
+    }
     auto body = buildRequestBody(request, config_, false);
     if(!body)
     {
@@ -733,6 +743,10 @@ mw::Task<mw::E<LlmResponse>> LlmClient::complete(LlmRequest request)
     const int max_retries = std::max(config_.max_retries, 0);
     for(int attempt = 0;; ++attempt)
     {
+        if(request.cancel.cancelled())
+        {
+            co_return std::unexpected(error::cancel("LLM request cancelled"));
+        }
         auto response =
             co_await http_->post(buildHttpRequest(*body, request_config));
         if(!response)
@@ -769,6 +783,10 @@ mw::Task<mw::E<LlmResponse>>
 LlmClient::completeStream(LlmRequest request,
                           std::function<void(const StreamEvent&)> on_event)
 {
+    if(request.cancel.cancelled())
+    {
+        co_return std::unexpected(error::cancel("LLM stream cancelled"));
+    }
     auto body = buildRequestBody(request, config_, true);
     if(!body)
     {
@@ -792,7 +810,7 @@ LlmClient::completeStream(LlmRequest request,
     internal::AsyncQueue<StreamEvent> queue;
     auto request_task = internal::StartedTask<mw::HTTPResponse>::start(
         streamRequest(*http_, buildHttpRequest(*body, request_config),
-                      frame_parser, delta_parser, queue));
+                      frame_parser, delta_parser, queue, request.cancel));
 
     std::optional<mw::Error> callback_error;
     while(auto event = co_await queue.pop())
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index ef536fe..6297fc3 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -1,4 +1,5 @@
 add_executable(agent_smith_test_skeleton
+    test_agent.cpp
     test_llm_client.cpp
     test_message.cpp
     test_memory.cpp
diff --git a/tests/test_agent.cpp b/tests/test_agent.cpp
new file mode 100644
index 0000000..dba3da1
--- /dev/null
+++ b/tests/test_agent.cpp
@@ -0,0 +1,297 @@
+#include <agent_smith/agent_smith.hpp>
+#include <gmock/gmock.h>
+#include <gtest/gtest.h>
+#include <mw/http_client_async_mock.hpp>
+#include <span>
+#include <stdexcept>
+
+namespace agent_smith
+{
+namespace
+{
+using ::testing::_;
+using ::testing::Invoke;
+
+mw::HTTPResponse reply(const nlohmann::json& message)
+{
+    return {200, nlohmann::json({{"choices", {{{"message", message}}}}}).dump()};
+}
+
+nlohmann::json callMessage(std::string name = "echo")
+{
+    return {{"role", "assistant"}, {"content", ""},
+            {"tool_calls", {{{"id", "a"}, {"type", "function"},
+                {"function", {{"name", name}, {"arguments", "{}"}}}}}}};
+}
+
+ToolSchema schema(std::string name = "echo")
+{
+    return {std::move(name), "test", {{"type", "object"},
+                                     {"properties", nlohmann::json::object()}}};
+}
+
+class AgentTest : public ::testing::Test
+{
+protected:
+    Runtime runtime;
+    mw::HTTPSessionAsyncMock http;
+    LlmClient client{runtime, http, {
+        .endpoint = *mw::URL::fromStr("https://example.test"),
+        .model = "test", .max_retries = 0}};
+    std::shared_ptr<FullMemory> memory = std::make_shared<FullMemory>();
+};
+
+TEST_F(AgentTest, RunsToolLoopAndReusesHistory)
+{
+    Agent agent(runtime, client, memory, {.stream_default = false});
+    agent.setSystemPrompt("system");
+    ASSERT_TRUE(agent.addTool(makeTool(schema(),
+        [](nlohmann::json, ToolContext) { return "tool output"; })));
+    std::vector<std::string> events;
+    agent.hooks().on_message = [&](const Message&) { events.push_back("message"); };
+    agent.hooks().on_tool_call = [&](const ToolCall&) { events.push_back("call"); };
+    agent.hooks().on_tool_result = [&](const ToolMessage&) { events.push_back("result"); };
+    int requests = 0;
+    EXPECT_CALL(http, post(_)).Times(3).WillRepeatedly(Invoke(
+        [&](mw::HTTPRequest request)
+        {
+            auto body = nlohmann::json::parse(request.request_data);
+            EXPECT_EQ(body["messages"][0]["content"], "system");
+            EXPECT_EQ(body["tools"][0]["function"]["name"], "echo");
+            ++requests;
+            if(requests == 1)
+            {
+                EXPECT_EQ(body["temperature"], 0.25);
+                return mw::HTTPSessionAsyncMock::complete(reply(callMessage()));
+            }
+            EXPECT_EQ(body["messages"][3]["role"], "tool");
+            EXPECT_EQ(body["messages"][3]["tool_call_id"], "a");
+            EXPECT_EQ(body["messages"][3]["content"], "tool output");
+            return mw::HTTPSessionAsyncMock::complete(reply(
+                {{"role", "assistant"}, {"content", "done"}}));
+        }));
+    auto result = syncWait(agent.send(AgentRequest{
+        .message = {"hello"}, .sampling = {.temperature = 0.25}}));
+    ASSERT_TRUE(result);
+    EXPECT_EQ(result->content, "done");
+    EXPECT_EQ(events, (std::vector<std::string>{"message", "call", "result", "message"}));
+    EXPECT_EQ(memory->size(), 4U);
+    EXPECT_TRUE(syncWait(agent.send("again")));
+    EXPECT_EQ(memory->size(), 6U);
+}
+
+mw::Task<mw::E<mw::HTTPResponse>> streamReply(mw::ChunkCallback callback)
+{
+    const std::string data =
+        "data: {\"choices\":[{\"delta\":{\"content\":\"hello\"},"
+        "\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n";
+    callback(std::as_bytes(std::span(data.data(), data.size())));
+    co_return mw::HTTPResponse(200, "");
+}
+
+TEST_F(AgentTest, StreamsAndIgnoresThrowingObservers)
+{
+    Agent agent(runtime, client, memory, {.stream_default = false});
+    int events = 0;
+    agent.hooks().on_stream_event = [&](const StreamEvent&)
+    { ++events; throw std::runtime_error("observer"); };
+    agent.hooks().on_message = [](const Message&) { throw 1; };
+    EXPECT_CALL(http, postStream(_, _)).WillOnce(Invoke(
+        [](mw::HTTPRequest request, mw::ChunkCallback callback)
+        {
+            EXPECT_TRUE(nlohmann::json::parse(request.request_data)["stream"]);
+            return streamReply(std::move(callback));
+        }));
+    auto result = syncWait(agent.send(AgentRequest{
+        .message = {"hi"}, .stream = true}));
+    ASSERT_TRUE(result);
+    EXPECT_EQ(result->content, "hello");
+    EXPECT_GT(events, 0);
+}
+
+TEST_F(AgentTest, ToolDoneStopsWithoutAnotherLlmRequest)
+{
+    Agent agent(runtime, client, memory, {.stream_default = false});
+    ASSERT_TRUE(agent.addTool(makeTool(schema(),
+        [](nlohmann::json, ToolContext)
+        { return nlohmann::json{{"_done", true}, {"final_message", "finished"}}; })));
+    EXPECT_CALL(http, post(_)).WillOnce(Invoke([](mw::HTTPRequest)
+    { return mw::HTTPSessionAsyncMock::complete(reply(callMessage())); }));
+    auto result = syncWait(agent.send("go"));
+    ASSERT_TRUE(result);
+    EXPECT_EQ(result->content, "finished");
+    EXPECT_EQ(memory->size(), 4U);
+}
+
+TEST_F(AgentTest, UnknownToolIsConversationErrorAndIterationCapStopsLoop)
+{
+    Agent agent(runtime, client, memory,
+                {.max_iterations = 2, .stream_default = false});
+    int errors = 0;
+    agent.hooks().on_error = [&](const mw::Error& error)
+    { ++errors; EXPECT_NE(error.as<CancelledError>(), nullptr); throw 1; };
+    int results = 0;
+    agent.hooks().on_tool_result = [&](const ToolMessage& result)
+    { ++results; EXPECT_TRUE(result.is_error); };
+    EXPECT_CALL(http, post(_)).Times(2).WillRepeatedly(Invoke([](mw::HTTPRequest)
+    { return mw::HTTPSessionAsyncMock::complete(reply(callMessage("missing"))); }));
+    auto result = syncWait(agent.send("go"));
+    EXPECT_FALSE(result);
+    EXPECT_EQ(errors, 1);
+    EXPECT_EQ(results, 2);
+}
+
+TEST_F(AgentTest, EnforcesDeadlineCancellationAndTreeCapBeforeRequest)
+{
+    EXPECT_CALL(http, post(_)).Times(0);
+    EXPECT_CALL(http, postStream(_, _)).Times(0);
+    Agent deadline(runtime, client, memory, {.deadline = std::chrono::seconds(0)});
+    EXPECT_FALSE(syncWait(deadline.send("go")));
+    Agent cancelled(runtime, client, memory);
+    cancelled.cancel();
+    EXPECT_FALSE(syncWait(cancelled.send("go")));
+    Agent tree(runtime, client, memory, {.tree_max_iterations = 0});
+    EXPECT_FALSE(syncWait(tree.send("go")));
+}
+
+TEST_F(AgentTest, TokenCapAndHttpFailureReturnErrors)
+{
+    Agent agent(runtime, client, memory,
+                {.max_total_tokens = 1, .stream_default = false});
+    EXPECT_CALL(http, post(_)).WillOnce(Invoke([](mw::HTTPRequest)
+    {
+        return mw::HTTPSessionAsyncMock::complete(mw::HTTPResponse(200,
+            R"({"choices":[{"message":{"role":"assistant","content":"done"}}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}})"));
+    }));
+    auto result = syncWait(agent.send("go"));
+    ASSERT_FALSE(result);
+    EXPECT_NE(result.error().as<CancelledError>(), nullptr);
+    EXPECT_CALL(http, post(_)).WillOnce(Invoke([](mw::HTTPRequest)
+    { return mw::HTTPSessionAsyncMock::complete(mw::HTTPResponse(400, "bad")); }));
+    result = syncWait(agent.send("again"));
+    ASSERT_FALSE(result);
+    EXPECT_NE(result.error().as<LlmApiError>(), nullptr);
+}
+
+class BrokenMemory final : public Memory
+{
+public:
+    void append(Message) override {}
+    std::size_t size() const override { return 0; }
+    void clear() override {}
+    mw::Task<mw::E<std::vector<Message>>>
+    snapshot(const SystemMessage&) override
+    {
+        co_return std::unexpected(error::parse("snapshot failed"));
+    }
+};
+
+TEST_F(AgentTest, MemoryFailureReachesErrorHookWithoutHttp)
+{
+    Agent agent(runtime, client, std::make_shared<BrokenMemory>());
+    int errors = 0;
+    agent.hooks().on_error = [&](const mw::Error& error)
+    { ++errors; EXPECT_NE(error.as<ParseError>(), nullptr); };
+    EXPECT_CALL(http, postStream(_, _)).Times(0);
+    auto result = syncWait(agent.send("go"));
+    ASSERT_FALSE(result);
+    EXPECT_EQ(result.error().msg(), "snapshot failed");
+    EXPECT_EQ(errors, 1);
+}
+
+mw::Task<mw::E<mw::HTTPResponse>> streamTool(mw::ChunkCallback callback)
+{
+    const std::string data =
+        R"(data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"a","type":"function","function":{"name":"echo","arguments":"{}"}}]},"finish_reason":"tool_calls"}]})"
+        "\n\ndata: [DONE]\n\n";
+    callback(std::as_bytes(std::span(data.data(), data.size())));
+    co_return mw::HTTPResponse(200, "");
+}
+
+TEST_F(AgentTest, StreamingToolRoundTripWorksWithoutStreamHook)
+{
+    Agent agent(runtime, client, memory);
+    ASSERT_TRUE(agent.addTool(makeTool(schema(),
+        [](nlohmann::json, ToolContext) { return "output"; })));
+    EXPECT_FALSE(agent.addTool(makeTool(schema(),
+        [](nlohmann::json, ToolContext) { return "duplicate"; })));
+    EXPECT_FALSE(agent.addTool(nullptr));
+    ::testing::InSequence sequence;
+    EXPECT_CALL(http, postStream(_, _)).WillOnce(Invoke(
+        [](mw::HTTPRequest, mw::ChunkCallback callback)
+        { return streamTool(std::move(callback)); }));
+    EXPECT_CALL(http, postStream(_, _)).WillOnce(Invoke(
+        [](mw::HTTPRequest request, mw::ChunkCallback callback)
+        {
+            const auto body = nlohmann::json::parse(request.request_data);
+            EXPECT_EQ(body["messages"].back()["content"], "output");
+            return streamReply(std::move(callback));
+        }));
+    auto result = syncWait(agent.send("go"));
+    ASSERT_TRUE(result);
+    EXPECT_EQ(result->content, "hello");
+    EXPECT_EQ(memory->size(), 4U);
+}
+
+TEST_F(AgentTest, CancellationFromToolHookPreventsInvocation)
+{
+    Agent agent(runtime, client, memory, {.stream_default = false});
+    bool invoked = false;
+    ASSERT_TRUE(agent.addTool(makeTool(schema(),
+        [&invoked](nlohmann::json, ToolContext)
+        { invoked = true; return "output"; })));
+    agent.hooks().on_tool_call = [&](const ToolCall&) { agent.cancel(); };
+    EXPECT_CALL(http, post(_)).WillOnce(Invoke([](mw::HTTPRequest)
+    { return mw::HTTPSessionAsyncMock::complete(reply(callMessage())); }));
+    EXPECT_FALSE(syncWait(agent.send("go")));
+    EXPECT_FALSE(invoked);
+}
+
+mw::Task<mw::E<mw::HTTPResponse>> cancellableStream(
+    mw::ChunkCallback callback, Runtime& runtime)
+{
+    const std::string first =
+        "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n";
+    callback(std::as_bytes(std::span(first.data(), first.size())));
+    co_await runtime.sleepFor(std::chrono::milliseconds(10));
+    const bool accepted = callback(
+        std::as_bytes(std::span(first.data(), first.size())));
+    EXPECT_FALSE(accepted);
+    co_return std::unexpected(mw::runtimeError("aborted"));
+}
+
+TEST_F(AgentTest, CancellationAbortsStreamOnNextChunk)
+{
+    Agent agent(runtime, client, memory);
+    agent.hooks().on_stream_event = [&](const StreamEvent&) { agent.cancel(); };
+    EXPECT_CALL(http, postStream(_, _)).WillOnce(Invoke(
+        [&](mw::HTTPRequest, mw::ChunkCallback callback)
+        { return cancellableStream(std::move(callback), runtime); }));
+    auto result = syncWait(agent.send("go"));
+    ASSERT_FALSE(result);
+    EXPECT_NE(result.error().as<CancelledError>(), nullptr);
+}
+
+TEST_F(AgentTest, ToolObservesParentCancellation)
+{
+    auto tree = std::make_shared<AgentTreeState>();
+    Agent agent(runtime, client, memory,
+                {.stream_default = false, .tree_state = tree});
+    bool observed = false;
+    ASSERT_TRUE(agent.addTool(makeTool(schema(),
+        [&](nlohmann::json, ToolContext context)
+        {
+            tree->cancel.cancel();
+            observed = context.cancel.cancelled();
+            return "cancelled";
+        })));
+    EXPECT_CALL(http, post(_)).WillOnce(Invoke([](mw::HTTPRequest)
+    { return mw::HTTPSessionAsyncMock::complete(reply(callMessage())); }));
+    auto result = syncWait(agent.send("go"));
+    ASSERT_FALSE(result);
+    EXPECT_TRUE(observed);
+    EXPECT_NE(result.error().as<CancelledError>(), nullptr);
+}
+
+} // namespace
+} // namespace agent_smith
diff --git a/tests/test_skeleton.cpp b/tests/test_skeleton.cpp
index c9583f3..6c31f76 100644
--- a/tests/test_skeleton.cpp
+++ b/tests/test_skeleton.cpp
@@ -43,13 +43,6 @@ TEST(RuntimeTest, ConstructsSharedHttpSession)
     EXPECT_EQ(runtime.http().transferTimeout(), std::chrono::seconds(60));
 }
 
-TEST(AgentTest, ConstructsEmptyAgent)
-{
-    Agent agent;
-
-    EXPECT_NE(&agent, nullptr);
-}
-
 TEST(RuntimeTest, RunsPlainBlockingResult)
 {
     Runtime runtime;