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;