diff --git a/libs/context/include/merak/token_counter.hpp b/libs/context/include/merak/token_counter.hpp index efba781b..25802ecc 100644 --- a/libs/context/include/merak/token_counter.hpp +++ b/libs/context/include/merak/token_counter.hpp @@ -28,9 +28,19 @@ class TokenCounter { int fit_in_budget(const std::vector& messages, int token_limit) const; + // Update authoritative token count from API response. + // Subsequent count() calls use this as baseline, only estimating + // messages beyond the authoritative count. + void update_authoritative(int prompt_tokens, int message_count) { + authoritative_total_ = prompt_tokens; + authoritative_message_count_ = message_count; + } + private: std::string model_; double chars_per_token_; + int authoritative_total_ = 0; + int authoritative_message_count_ = 0; }; } // namespace merak diff --git a/libs/context/src/token_counter.cpp b/libs/context/src/token_counter.cpp index 08e30d5e..f7608ff4 100644 --- a/libs/context/src/token_counter.cpp +++ b/libs/context/src/token_counter.cpp @@ -22,6 +22,15 @@ int TokenCounter::count(const Message& msg) const { } int TokenCounter::count(const std::vector& messages) const { + // Hybrid: authoritative baseline from API + heuristic for new messages + if (authoritative_total_ > 0 && (int)messages.size() >= authoritative_message_count_) { + int incremental = 0; + for (int i = authoritative_message_count_; i < (int)messages.size(); i++) { + incremental += count(messages[i]); + } + return authoritative_total_ + incremental; + } + // Cold start: pure heuristic int total = 0; for (auto& msg : messages) { total += count(msg); diff --git a/libs/llm/include/merak/anthropic_provider.hpp b/libs/llm/include/merak/anthropic_provider.hpp index a23fb434..1eaa57db 100644 --- a/libs/llm/include/merak/anthropic_provider.hpp +++ b/libs/llm/include/merak/anthropic_provider.hpp @@ -23,11 +23,11 @@ class AnthropicProvider : public LlmProvider { bool test_connection() override; const CacheStats& cache_stats() const { return stats_; } + nlohmann::json build_request_body(const ChatRequest& request) const; + private: LLMConfig config_; CacheStats stats_; - - nlohmann::json build_request_body(const ChatRequest& request) const; }; } // namespace merak diff --git a/libs/llm/include/merak/llm_provider.hpp b/libs/llm/include/merak/llm_provider.hpp index 6ed705e4..75211e68 100644 --- a/libs/llm/include/merak/llm_provider.hpp +++ b/libs/llm/include/merak/llm_provider.hpp @@ -28,6 +28,11 @@ struct ChatRequest { bool enable_thinking = true; }; +struct RetryConfig { + int max_retries = 3; + int base_delay_ms = 1000; +}; + class LlmProvider { public: virtual ~LlmProvider() = default; diff --git a/libs/llm/include/merak/openai_provider.hpp b/libs/llm/include/merak/openai_provider.hpp index 9c1a2d10..48873c88 100644 --- a/libs/llm/include/merak/openai_provider.hpp +++ b/libs/llm/include/merak/openai_provider.hpp @@ -23,12 +23,12 @@ class OpenAIProvider : public LlmProvider { bool test_connection() override; const CacheStats& cache_stats() const { return stats_; } + nlohmann::json build_messages(const std::vector& msgs) const; + nlohmann::json build_tools(const std::vector& tools) const; + private: LLMConfig config_; CacheStats stats_; - - nlohmann::json build_messages(const std::vector& msgs) const; - nlohmann::json build_tools(const std::vector& tools) const; }; } // namespace merak diff --git a/libs/llm/src/anthropic_provider.cpp b/libs/llm/src/anthropic_provider.cpp index 48734f04..5d2a85a2 100644 --- a/libs/llm/src/anthropic_provider.cpp +++ b/libs/llm/src/anthropic_provider.cpp @@ -2,6 +2,8 @@ #include #include #include +#include +#include namespace merak { @@ -130,28 +132,6 @@ std::future AnthropicProvider::chat( std::string body_str = body.dump(); spdlog::debug("Anthropic request: url={}, body_size={}", url, body_str.size()); - CURL* curl = curl_easy_init(); - struct curl_slist* headers = nullptr; - headers = curl_slist_append(headers, - ("x-api-key: " + config_.api_key).c_str()); - headers = curl_slist_append(headers, - "anthropic-version: 2023-06-01"); - curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); - curl_easy_setopt(curl, CURLOPT_COPYPOSTFIELDS, body_str.c_str()); - headers = curl_slist_append(headers, "Content-Type: application/json"); - curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); - curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, 10000L); - curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1L); - curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 30L); - curl_easy_setopt(curl, CURLOPT_TIMEOUT, 300L); - curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); - curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, - +[](void* userdata, curl_off_t, curl_off_t, curl_off_t, curl_off_t) -> int { - auto* token = static_cast(userdata); - return token && token->cancelled() ? 1 : 0; - }); - curl_easy_setopt(curl, CURLOPT_XFERINFODATA, cancellation.get()); - // SSE 累积状态 std::string response_text; int input_tokens = 0, output_tokens = 0; @@ -279,49 +259,106 @@ std::future AnthropicProvider::chat( } }; - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, - +[](char* ptr, size_t size, size_t nmemb, void* userdata) -> size_t { - auto* cb = static_cast(userdata); - std::string data(ptr, size * nmemb); - (*cb)(data); - return size * nmemb; - }); + RetryConfig retry; + int delay = retry.base_delay_ms; + CURLcode res = CURLE_OK; + long http_code = 0; - curl_easy_setopt(curl, CURLOPT_WRITEDATA, &write_callback); + for (int attempt = 0; attempt <= retry.max_retries; attempt++) { + response_text.clear(); + input_tokens = 0; + output_tokens = 0; + has_usage = false; + pending_tools.clear(); + preserved_content_blocks.clear(); + accumulated_tool_calls.clear(); + current_event.clear(); + current_data.clear(); + line_buffer.clear(); - CURLcode res = curl_easy_perform(curl); + CURL* curl = curl_easy_init(); + if (!curl) { + res = CURLE_OUT_OF_MEMORY; + http_code = 0; + if (attempt == retry.max_retries) { + throw AgentError(ErrorType::LLM_ERROR, "Failed to initialize curl handle"); + } + spdlog::warn("Provider: retry {}/{} after {}ms (curl_easy_init failed)", + attempt + 1, retry.max_retries, delay); + std::this_thread::sleep_for(std::chrono::milliseconds(delay)); + delay *= 2; + continue; + } + struct curl_slist* hdrs = nullptr; + hdrs = curl_slist_append(hdrs, + ("x-api-key: " + config_.api_key).c_str()); + hdrs = curl_slist_append(hdrs, + "anthropic-version: 2023-06-01"); + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_COPYPOSTFIELDS, body_str.c_str()); + hdrs = curl_slist_append(hdrs, "Content-Type: application/json"); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, 10000L); + curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1L); + curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 30L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 300L); + curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); + curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, + +[](void* userdata, curl_off_t, curl_off_t, curl_off_t, curl_off_t) -> int { + auto* token = static_cast(userdata); + return token && token->cancelled() ? 1 : 0; + }); + curl_easy_setopt(curl, CURLOPT_XFERINFODATA, cancellation.get()); - long http_code = 0; - curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, + +[](char* ptr, size_t size, size_t nmemb, void* userdata) -> size_t { + auto* cb = static_cast(userdata); + std::string data(ptr, size * nmemb); + (*cb)(data); + return size * nmemb; + }); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &write_callback); - if (res != CURLE_OK) { - spdlog::error("curl error: {}", curl_easy_strerror(res)); + res = curl_easy_perform(curl); + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); curl_easy_cleanup(curl); - curl_slist_free_all(headers); - throw AgentError( - cancellation && cancellation->cancelled() - ? ErrorType::LLM_TIMEOUT : ErrorType::LLM_ERROR, - cancellation && cancellation->cancelled() - ? "LLM request cancelled" : curl_easy_strerror(res)); - } + curl_slist_free_all(hdrs); - if (http_code >= 400) { - curl_easy_cleanup(curl); - curl_slist_free_all(headers); + // Success + if (res == CURLE_OK && http_code < 400) break; + + // Never retry cancellation + if (cancellation && cancellation->cancelled()) { + throw AgentError(ErrorType::LLM_TIMEOUT, "LLM request cancelled"); + } + + // Never retry auth errors if (http_code == 401 || http_code == 403) { throw AgentError(ErrorType::LLM_ERROR, "LLM authentication failed (HTTP " + std::to_string(http_code) + ")"); } - if (http_code == 429) { + + // Not retryable: other 4xx + if (http_code >= 400 && http_code < 500 && http_code != 429) { throw AgentError(ErrorType::LLM_ERROR, - "Rate limited (HTTP 429)"); + "LLM API error (HTTP " + std::to_string(http_code) + ")"); + } + + // Exhausted retries + if (attempt == retry.max_retries) { + if (res != CURLE_OK) { + throw AgentError(ErrorType::LLM_ERROR, curl_easy_strerror(res)); + } + throw AgentError(ErrorType::LLM_ERROR, + "LLM API error after retries (HTTP " + std::to_string(http_code) + ")"); } - throw AgentError(ErrorType::LLM_ERROR, - "LLM API error (HTTP " + std::to_string(http_code) + ")"); - } - curl_easy_cleanup(curl); - curl_slist_free_all(headers); + // Backoff and retry + spdlog::warn("Provider: retry {}/{} after {}ms (HTTP {}, curl {})", + attempt + 1, retry.max_retries, delay, http_code, (int)res); + std::this_thread::sleep_for(std::chrono::milliseconds(delay)); + delay *= 2; + } AgentResponse response; response.tool_calls = std::move(accumulated_tool_calls); diff --git a/libs/llm/src/openai_provider.cpp b/libs/llm/src/openai_provider.cpp index 19d79384..08b1978c 100644 --- a/libs/llm/src/openai_provider.cpp +++ b/libs/llm/src/openai_provider.cpp @@ -2,6 +2,8 @@ #include #include #include +#include +#include namespace merak { @@ -35,26 +37,6 @@ std::future OpenAIProvider::chat( std::string body_str = body.dump(); spdlog::debug("OpenAI request: url={}, body_size={}", url, body_str.size()); - CURL* curl = curl_easy_init(); - struct curl_slist* headers = nullptr; - headers = curl_slist_append(headers, - ("Authorization: Bearer " + config_.api_key).c_str()); - curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); - curl_easy_setopt(curl, CURLOPT_COPYPOSTFIELDS, body_str.c_str()); - headers = curl_slist_append(headers, "Content-Type: application/json"); - curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); - curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, 10000L); - curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1L); - curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 30L); - curl_easy_setopt(curl, CURLOPT_TIMEOUT, 300L); - curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); - curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, - +[](void* userdata, curl_off_t, curl_off_t, curl_off_t, curl_off_t) -> int { - auto* token = static_cast(userdata); - return token && token->cancelled() ? 1 : 0; - }); - curl_easy_setopt(curl, CURLOPT_XFERINFODATA, cancellation.get()); - std::string response_text; int input_tokens = 0, output_tokens = 0; bool has_usage = false; @@ -135,49 +117,100 @@ std::future OpenAIProvider::chat( } }; - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, - +[](char* ptr, size_t size, size_t nmemb, void* userdata) -> size_t { - auto* cb = static_cast(userdata); - std::string data(ptr, size * nmemb); - (*cb)(data); - return size * nmemb; - }); + RetryConfig retry; + int delay = retry.base_delay_ms; + CURLcode res = CURLE_OK; + long http_code = 0; - curl_easy_setopt(curl, CURLOPT_WRITEDATA, &write_callback); + for (int attempt = 0; attempt <= retry.max_retries; attempt++) { + response_text.clear(); + input_tokens = 0; + output_tokens = 0; + has_usage = false; + accumulated_tool_calls_json = nlohmann::json::array(); + line_buffer.clear(); - CURLcode res = curl_easy_perform(curl); + CURL* curl = curl_easy_init(); + if (!curl) { + res = CURLE_OUT_OF_MEMORY; + http_code = 0; + if (attempt == retry.max_retries) { + throw AgentError(ErrorType::LLM_ERROR, "Failed to initialize curl handle"); + } + spdlog::warn("Provider: retry {}/{} after {}ms (curl_easy_init failed)", + attempt + 1, retry.max_retries, delay); + std::this_thread::sleep_for(std::chrono::milliseconds(delay)); + delay *= 2; + continue; + } + struct curl_slist* hdrs = nullptr; + hdrs = curl_slist_append(hdrs, + ("Authorization: Bearer " + config_.api_key).c_str()); + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_COPYPOSTFIELDS, body_str.c_str()); + hdrs = curl_slist_append(hdrs, "Content-Type: application/json"); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, 10000L); + curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1L); + curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 30L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 300L); + curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); + curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, + +[](void* userdata, curl_off_t, curl_off_t, curl_off_t, curl_off_t) -> int { + auto* token = static_cast(userdata); + return token && token->cancelled() ? 1 : 0; + }); + curl_easy_setopt(curl, CURLOPT_XFERINFODATA, cancellation.get()); - long http_code = 0; - curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, + +[](char* ptr, size_t size, size_t nmemb, void* userdata) -> size_t { + auto* cb = static_cast(userdata); + std::string data(ptr, size * nmemb); + (*cb)(data); + return size * nmemb; + }); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &write_callback); - if (res != CURLE_OK) { - spdlog::error("curl error: {}", curl_easy_strerror(res)); + res = curl_easy_perform(curl); + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); curl_easy_cleanup(curl); - curl_slist_free_all(headers); - throw AgentError( - cancellation && cancellation->cancelled() - ? ErrorType::LLM_TIMEOUT : ErrorType::LLM_ERROR, - cancellation && cancellation->cancelled() - ? "LLM request cancelled" : curl_easy_strerror(res)); - } + curl_slist_free_all(hdrs); - if (http_code >= 400) { - curl_easy_cleanup(curl); - curl_slist_free_all(headers); + // Success + if (res == CURLE_OK && http_code < 400) break; + + // Never retry cancellation + if (cancellation && cancellation->cancelled()) { + throw AgentError(ErrorType::LLM_TIMEOUT, "LLM request cancelled"); + } + + // Never retry auth errors if (http_code == 401 || http_code == 403) { throw AgentError(ErrorType::LLM_ERROR, "LLM authentication failed (HTTP " + std::to_string(http_code) + ")"); } - if (http_code == 429) { + + // Not retryable: other 4xx + if (http_code >= 400 && http_code < 500 && http_code != 429) { throw AgentError(ErrorType::LLM_ERROR, - "Rate limited (HTTP 429)"); + "LLM API error (HTTP " + std::to_string(http_code) + ")"); + } + + // Exhausted retries + if (attempt == retry.max_retries) { + if (res != CURLE_OK) { + throw AgentError(ErrorType::LLM_ERROR, curl_easy_strerror(res)); + } + throw AgentError(ErrorType::LLM_ERROR, + "LLM API error after retries (HTTP " + std::to_string(http_code) + ")"); } - throw AgentError(ErrorType::LLM_ERROR, - "LLM API error (HTTP " + std::to_string(http_code) + ")"); - } - curl_easy_cleanup(curl); - curl_slist_free_all(headers); + // Backoff and retry + spdlog::warn("Provider: retry {}/{} after {}ms (HTTP {}, curl {})", + attempt + 1, retry.max_retries, delay, http_code, (int)res); + std::this_thread::sleep_for(std::chrono::milliseconds(delay)); + delay *= 2; + } AgentResponse response; for (auto& tc_json : accumulated_tool_calls_json) { diff --git a/libs/llm/tests/test_anthropic_provider.cpp b/libs/llm/tests/test_anthropic_provider.cpp new file mode 100644 index 00000000..66e29e8c --- /dev/null +++ b/libs/llm/tests/test_anthropic_provider.cpp @@ -0,0 +1,159 @@ +#include +#include +#include +#include + +using namespace merak; + +static int tests_run = 0; +static int tests_passed = 0; + +#define TEST(name) tests_run++; std::cout << " " << name << " ... " +#define PASS() tests_passed++; std::cout << "PASS" << std::endl + +void test_build_body_system_prompt_format() { + TEST("system prompt as Anthropic array-of-blocks"); + LLMConfig cfg; + cfg.api_key = "sk-ant-test"; + cfg.api_base_url = "https://api.anthropic.com/v1"; + cfg.default_model = "claude-sonnet-4-20250514"; + AnthropicProvider provider(cfg); + + ChatRequest req; + req.model = "claude-sonnet-4-20250514"; + req.max_output_tokens = 1024; + req.messages = {{"system", "You are helpful.", {}, {}, ""}, + {"user", "hello", {}, {}, ""}}; + req.enable_cache = true; + + auto body = provider.build_request_body(req); + assert(body.contains("system") && body["system"].is_array()); + auto& sys = body["system"]; + assert(sys.size() >= 1); + assert(sys[0]["type"] == "text"); + assert(sys[0]["text"] == "You are helpful."); + + // cache_control should be injected + assert(sys[0].contains("cache_control")); + assert(sys[0]["cache_control"]["type"] == "ephemeral"); + (void)sys; + PASS(); +} + +void test_build_body_tool_use_format() { + TEST("tool_use content blocks in assistant messages"); + LLMConfig cfg; + cfg.api_key = "sk-ant-test"; + cfg.api_base_url = "https://api.anthropic.com/v1"; + cfg.default_model = "claude-sonnet-4-20250514"; + AnthropicProvider provider(cfg); + + ChatRequest req; + req.model = "claude-sonnet-4-20250514"; + req.max_output_tokens = 1024; + Message assistant; + assistant.role = "assistant"; + assistant.content = "Let me read that file."; + assistant.tool_calls = {{"call_1", "read_file", R"({"path":"/x.txt"})"}}; + req.messages = {{"user", "read /x.txt", {}, {}, ""}, assistant}; + req.enable_cache = false; + + auto body = provider.build_request_body(req); + auto& msgs = body["messages"]; + assert(msgs.is_array() && msgs.size() == 2); + + auto& asst_content = msgs[1]["content"]; + assert(asst_content.is_array()); + + // Find the tool_use block + bool found_tool_use = false; + for (auto& block : asst_content) { + if (block["type"] == "tool_use") { + found_tool_use = true; + assert(block["name"] == "read_file"); + break; + } + } + assert(found_tool_use); + (void)found_tool_use; + PASS(); +} + +void test_build_body_tool_definitions_with_cache() { + TEST("cache_control on last tool when enable_cache=true"); + LLMConfig cfg; + cfg.api_key = "sk-ant-test"; + cfg.api_base_url = "https://api.anthropic.com/v1"; + cfg.default_model = "claude-sonnet-4-20250514"; + AnthropicProvider provider(cfg); + + ChatRequest req; + req.model = "claude-sonnet-4-20250514"; + req.max_output_tokens = 1024; + req.messages = {{"user", "hi", {}, {}, ""}}; + req.enable_cache = true; + req.tools = { + {"read_file", "Read a file", "", {}}, + {"write_file", "Write a file", "", {}}, + }; + + auto body = provider.build_request_body(req); + assert(body.contains("tools") && body["tools"].is_array()); + auto& last_tool = body["tools"].back(); + assert(last_tool.contains("cache_control")); + assert(last_tool["cache_control"]["type"] == "ephemeral"); + (void)last_tool; + PASS(); +} + +void test_cache_control_absent_when_disabled() { + TEST("no cache_control when enable_cache=false"); + LLMConfig cfg; + cfg.api_key = "sk-ant-test"; + cfg.api_base_url = "https://api.anthropic.com/v1"; + cfg.default_model = "claude-sonnet-4-20250514"; + AnthropicProvider provider(cfg); + + ChatRequest req; + req.model = "claude-sonnet-4-20250514"; + req.max_output_tokens = 1024; + req.messages = {{"system", "You are helpful.", {}, {}, ""}, + {"user", "hi", {}, {}, ""}}; + req.enable_cache = false; + req.tools = {{"read_file", "Read a file", "", {}}}; + + auto body = provider.build_request_body(req); + assert(!body["system"][0].contains("cache_control")); + assert(!body["tools"].back().contains("cache_control")); + PASS(); +} + +void test_thinking_config_disabled() { + TEST("thinking config not injected when disabled"); + LLMConfig cfg; + cfg.api_key = "sk-ant-test"; + cfg.api_base_url = "https://api.anthropic.com/v1"; + cfg.default_model = "claude-sonnet-4-20250514"; + AnthropicProvider provider(cfg); + + ChatRequest req; + req.model = "claude-sonnet-4-20250514"; + req.max_output_tokens = 1024; + req.messages = {{"user", "hi", {}, {}, ""}}; + req.enable_thinking = false; + + auto body = provider.build_request_body(req); + assert(!body.contains("thinking")); + PASS(); +} + +int main() { + std::cout << "\nAnthropic Provider Tests\n========================\n"; + test_build_body_system_prompt_format(); + test_build_body_tool_use_format(); + test_build_body_tool_definitions_with_cache(); + test_cache_control_absent_when_disabled(); + test_thinking_config_disabled(); + std::cout << "\n" << tests_passed << "/" << tests_run << " passed\n"; + return tests_passed == tests_run ? 0 : 1; +} diff --git a/libs/llm/tests/test_openai_provider.cpp b/libs/llm/tests/test_openai_provider.cpp new file mode 100644 index 00000000..54f0ac96 --- /dev/null +++ b/libs/llm/tests/test_openai_provider.cpp @@ -0,0 +1,109 @@ +#include +#include +#include + +using namespace merak; + +static int tests_run = 0; +static int tests_passed = 0; + +#define TEST(name) \ + tests_run++; \ + std::cout << " " << name << " ... " + +#define PASS() \ + tests_passed++; \ + std::cout << "PASS" << std::endl + +void test_build_messages_basic() { + TEST("build_messages with simple messages"); + LLMConfig cfg; + cfg.api_key = "sk-test"; + cfg.api_base_url = "https://api.openai.com/v1"; + cfg.default_model = "gpt-4o"; + OpenAIProvider provider(cfg); + + std::vector msgs = { + {"user", "hello", {}, {}, ""}, + {"assistant", "hi there", {}, {}, ""}, + }; + auto arr = provider.build_messages(msgs); + assert(arr.is_array() && arr.size() == 2); + assert(arr[0]["role"] == "user" && arr[0]["content"] == "hello"); + assert(arr[1]["role"] == "assistant" && arr[1]["content"] == "hi there"); + PASS(); +} + +void test_build_messages_with_tool_calls() { + TEST("build_messages with tool_calls"); + LLMConfig cfg; + cfg.api_key = "sk-test"; + cfg.api_base_url = "https://api.openai.com/v1"; + OpenAIProvider provider(cfg); + + std::vector msgs = { + {"user", "read file", {}, {}, ""}, + }; + Message assistant; + assistant.role = "assistant"; + assistant.tool_calls = {{"call_1", "read_file", R"({"path":"/x"})"}}; + msgs.push_back(assistant); + + Message tool; + tool.role = "tool"; + tool.content = "file contents"; + tool.tool_call_id = "call_1"; + msgs.push_back(tool); + + auto arr = provider.build_messages(msgs); + assert(arr.is_array() && arr.size() == 3); + assert(arr[1].contains("tool_calls") && arr[1]["tool_calls"].is_array()); + assert(arr[1]["tool_calls"][0]["id"] == "call_1"); + assert(arr[2]["tool_call_id"] == "call_1"); + assert(arr[2]["content"] == "file contents"); + PASS(); +} + +void test_build_tools_empty() { + TEST("build_tools with empty list"); + LLMConfig cfg; + cfg.api_key = "sk-test"; + cfg.api_base_url = "https://api.openai.com/v1"; + OpenAIProvider provider(cfg); + + auto arr = provider.build_tools({}); + assert(arr.is_array() && arr.empty()); + PASS(); +} + +void test_build_tools_with_specs() { + TEST("build_tools with named tools"); + LLMConfig cfg; + cfg.api_key = "sk-test"; + cfg.api_base_url = "https://api.openai.com/v1"; + OpenAIProvider provider(cfg); + + ToolSpec t1, t2; + t1.name = "read_file"; + t1.description = "Read a file"; + t2.name = "write_file"; + t2.description = "Write a file"; + t2.parameters_json = R"({"type":"object"})"; + + auto arr = provider.build_tools({t1, t2}); + assert(arr.is_array() && arr.size() == 2); + assert(arr[0]["type"] == "function"); + assert(arr[0]["function"]["name"] == "read_file"); + assert(arr[1]["function"]["parameters"]["type"] == "object"); + PASS(); +} + +int main() { + std::cout << "\nOpenAI Provider Tests\n====================\n"; + test_build_messages_basic(); + test_build_messages_with_tool_calls(); + test_build_tools_empty(); + test_build_tools_with_specs(); + std::cout << "\n" << tests_passed << "/" << tests_run << " passed\n"; + return tests_passed == tests_run ? 0 : 1; +} diff --git a/libs/loop/include/merak/agent_loop.hpp b/libs/loop/include/merak/agent_loop.hpp index df4ff2ea..67c6de44 100644 --- a/libs/loop/include/merak/agent_loop.hpp +++ b/libs/loop/include/merak/agent_loop.hpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -50,7 +51,7 @@ class AgentLoop { // Load history from persistent storage (journal restore). void restore_history(std::vector history); - // Replace or insert system message at position 0. + // Store the runtime system prompt (used by build_context if non-empty). void set_system_prompt(const std::string& prompt); // Set a callback that provides narrative working memory context text. @@ -88,6 +89,7 @@ class AgentLoop { std::shared_ptr tools_; std::shared_ptr memory_; std::shared_ptr compactor_; + std::shared_ptr token_counter_; std::unique_ptr pipeline_; StallDetector stall_detector_; @@ -95,6 +97,8 @@ class AgentLoop { TurnIngestor turn_ingestor_; std::vector session_history_; + std::string system_prompt_; + std::vector compaction_summaries_; std::shared_ptr> plan_mode_; std::function working_memory_provider_; std::shared_ptr worldbuilding_; diff --git a/libs/loop/src/agent_loop.cpp b/libs/loop/src/agent_loop.cpp index ebb297d5..dabc9ba5 100644 --- a/libs/loop/src/agent_loop.cpp +++ b/libs/loop/src/agent_loop.cpp @@ -25,20 +25,19 @@ AgentLoop::AgentLoop( , worldbuilding_(std::move(worldbuilding)) , skills_(std::move(skills)) , pipeline_(std::make_unique()) + , token_counter_(std::make_shared(config_.default_model)) { pipeline_->set_compactor(compactor_); } void AgentLoop::restore_history(std::vector history) { session_history_ = std::move(history); + compaction_summaries_.clear(); + token_counter_->update_authoritative(0, 0); } void AgentLoop::set_system_prompt(const std::string& prompt) { - if (!session_history_.empty() && session_history_[0].role == "system") { - session_history_[0].content = prompt; - } else { - session_history_.insert(session_history_.begin(), {"system", prompt, {}, {}, ""}); - } + system_prompt_ = prompt; } void AgentLoop::transition_to(TurnState next, RunControl& control) { @@ -103,8 +102,9 @@ AgentResponse AgentLoop::run_loop(RunControl& control) { if (config_.enable_cache) { auto split = CacheAwareContext::split(context_messages); - spdlog::debug("Loop: turn {} — {}", turn_count, - CacheAwareContext::info(split)); + context_messages = split.static_prefix; + context_messages.insert(context_messages.end(), + split.dynamic_suffix.begin(), split.dynamic_suffix.end()); } transition_to(TurnState::Thinking, control); @@ -134,11 +134,6 @@ AgentResponse AgentLoop::run_loop(RunControl& control) { std::vector accumulated_tool_calls; - // Snapshot: if LLM streaming fails mid-flight, chunks already emitted - // via the callback would be duplicated by the retry. Save and restore - // the pre-attempt text length so only the successful response is kept. - const auto text_len_before_attempt = response.text.size(); - auto llm_future = llm_->chat(req, [&](StreamChunk chunk) { auto token = control.cancellation_token(); @@ -151,69 +146,19 @@ AgentResponse AgentLoop::run_loop(RunControl& control) { }, control.cancellation_token()); AgentResponse llm_response; - int attempt = 0; - while (true) { - try { - llm_response = llm_future.get(); - break; - } catch (const std::exception& e) { - // Restore text to pre-attempt state so retry doesn't duplicate - // partial chunks already emitted by the failed future. - response.text.resize(text_len_before_attempt); - attempt++; - int http_status = 0; - std::string msg = e.what(); - auto pos = msg.rfind("(HTTP "); - if (pos != std::string::npos) { - http_status = std::stoi(msg.substr(pos + 6)); - } - auto error_class = turn_ingestor_.classify_error(http_status, msg); - - if (attempt > config_.max_retries) { - spdlog::error("Loop: max retries ({}) exceeded", config_.max_retries); - throw; - } - - switch (error_class) { - case LlmErrorClass::Auth: - case LlmErrorClass::Cancelled: - throw; // never retry these - case LlmErrorClass::ContextWindow: - spdlog::warn("Loop: context window error, triggering compaction"); - maybe_compact(control); - break; - case LlmErrorClass::RateLimit: - case LlmErrorClass::StreamTransport: - case LlmErrorClass::StreamIdle: - case LlmErrorClass::Unknown: - default: { - int delay_ms = std::min(2000 * (1 << (attempt - 1)), 30000); - spdlog::warn("Loop: retry {}/{} after {}ms ({}: {})", - attempt, config_.max_retries, delay_ms, - (int)error_class, e.what()); - std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms)); - break; - } - case LlmErrorClass::None: - break; - } - - llm_future = llm_->chat(req, - [&](StreamChunk chunk) { - auto token = control.cancellation_token(); - if (token && token->should_stop()) return; - if (chunk.is_final) return; - if (!chunk.is_tool_call) { - control.emit_text_delta(chunk.text); - response.text += chunk.text; - } - }, control.cancellation_token()); - } + try { + llm_response = llm_future.get(); + } catch (const std::exception& e) { + spdlog::error("Loop: LLM request failed after provider retries: {}", e.what()); + throw; } response.total_input_tokens += llm_response.total_input_tokens; response.total_output_tokens += llm_response.total_output_tokens; response.has_usage = response.has_usage || llm_response.has_usage; response.usage_missing = response.usage_missing || !llm_response.has_usage; + token_counter_->update_authoritative( + llm_response.total_input_tokens, + (int)context_messages.size()); control.emit_usage(llm_response.total_input_tokens, llm_response.total_output_tokens, llm_response.has_usage); if (auto token = control.cancellation_token(); token && token->should_stop()) throw AgentError(ErrorType::INTERNAL_ERROR, "Run cancelled"); @@ -500,11 +445,22 @@ std::vector AgentLoop::build_context() { } sources.conversation_messages = memory_->recent_history(config_.max_turns); + // Use runtime-updated system prompt if set, otherwise config default + const std::string& effective_system_prompt = system_prompt_.empty() + ? config_.system_prompt : system_prompt_; + auto payload = pipeline_->planned_assemble( - config_.system_prompt, config_.default_model, + effective_system_prompt, config_.default_model, config_.model_max_tokens, session_history_, sources); - return payload.messages; + // Prepend compaction summaries before returning (they are NOT part + // of session_history_ to avoid index conflicts) + auto messages = payload.messages; + if (!compaction_summaries_.empty()) { + messages.insert(messages.begin(), + compaction_summaries_.begin(), compaction_summaries_.end()); + } + return messages; } std::vector AgentLoop::handle_tool_calls( @@ -643,8 +599,7 @@ std::vector AgentLoop::handle_tool_calls( void AgentLoop::maybe_compact(RunControl& control) { if (!config_.enable_compaction) return; - TokenCounter counter(config_.default_model); - int total_tokens = counter.count(session_history_); + int total_tokens = token_counter_->count(session_history_); // Microcompact is handled by ContextOptimizer during pipeline assembly. // Here we trigger LLM-based compaction when token pressure is high. @@ -656,7 +611,7 @@ void AgentLoop::maybe_compact(RunControl& control) { Message summary_msg; summary_msg.role = "system"; summary_msg.content = "[Previous conversation summary]\n" + result.summary; - session_history_.insert(session_history_.begin(), summary_msg); + compaction_summaries_.push_back(summary_msg); control.record_compaction(static_cast(result.replaced.size())); } } diff --git a/libs/loop/src/sub_agent_runner.cpp b/libs/loop/src/sub_agent_runner.cpp index 701d3e17..0825f762 100644 --- a/libs/loop/src/sub_agent_runner.cpp +++ b/libs/loop/src/sub_agent_runner.cpp @@ -72,20 +72,24 @@ std::future> SubAgentRunner::fan_out( return std::async(std::launch::async, [this, tasks]() -> std::map { + const int max_parallel = std::min( + 4, (int)std::thread::hardware_concurrency()); std::map results; - std::vector>> futures; - - for (auto& d : tasks) { - futures.push_back(std::async(std::launch::async, - [this, d]() -> std::pair { - auto resp = delegate(d.agent_id, d.task).get(); - return {d.agent_id, resp}; - })); - } - for (auto& f : futures) { - auto [id, resp] = f.get(); - results[id] = resp; + size_t idx = 0; + while (idx < tasks.size()) { + std::vector>> batch; + for (int i = 0; i < max_parallel && idx < tasks.size(); i++, idx++) { + batch.push_back(std::async(std::launch::async, + [this, d = tasks[idx]]() -> std::pair { + auto resp = delegate(d.agent_id, d.task).get(); + return {d.agent_id, resp}; + })); + } + for (auto& f : batch) { + auto [id, resp] = f.get(); + results[id] = resp; + } } spdlog::info("SubAgentRunner: fan_out {} tasks completed", tasks.size()); diff --git a/libs/loop/tests/test_agent_loop.cpp b/libs/loop/tests/test_agent_loop.cpp new file mode 100644 index 00000000..66484719 --- /dev/null +++ b/libs/loop/tests/test_agent_loop.cpp @@ -0,0 +1,238 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace merak; + +static int tests_run = 0; +static int tests_passed = 0; + +#define TEST(name) \ + tests_run++; \ + std::cout << " " << name << " ... " +#define PASS() \ + tests_passed++; \ + std::cout << "PASS" << std::endl + +// ——— Stubs ———————————————————————————————————————————————— + +// Minimal stub provider that returns a fixed response. +// Chat is never called by Config-only tests, but the shared_ptr +// must be valid for construction. +class StubProvider : public LlmProvider { +public: + AgentResponse canned; + std::string name() const override { return "stub"; } + std::future chat( + const ChatRequest&, + std::function on_chunk, + std::shared_ptr) override + { + // Simulate streaming so response.text is populated via callback. + if (on_chunk) { + StreamChunk chunk; + chunk.text = canned.text; + on_chunk(chunk); + } + std::promise p; + auto future = p.get_future(); + p.set_value(canned); + return future; + } +}; + +// Minimal stub embedding provider so MemoryStore can be constructed +// without requiring a real embedding service. +class StubEmbeddingProvider : public EmbeddingProvider { +public: + std::future> embed(const std::string&) override { + std::promise> p; + p.set_value({0.0f}); + return p.get_future(); + } + int dimension() const override { return 1; } + std::future>> embed_batch( + const std::vector&) override + { + std::promise>> p; + p.set_value({{0.0f}}); + return p.get_future(); + } +}; + +// Helper: build a valid AgentLoop with stub dependencies. +// Only the dependencies that are actually accessed during +// construction / state queries need to be valid. +static std::unique_ptr make_test_loop( + AgentLoop::Config cfg = AgentLoop::Config{}) +{ + auto llm = std::make_shared(); + llm->canned.text = "ok"; + auto tools = std::make_shared(); + auto mem = std::make_shared( + MemoryConfig{}, + std::make_shared()); + auto counter = std::make_shared("gpt-4o"); + auto comp = std::make_shared(llm, counter, "gpt-4o"); + return std::make_unique( + cfg, llm, tools, mem, comp, nullptr, nullptr); +} + +// ——— Tests —————————————————————————————————————————————————— + +void test_max_turns_config_default() { + TEST("Config max_turns defaults to 25"); + AgentLoop::Config cfg; + assert(cfg.max_turns == 25); + PASS(); +} + +void test_enable_cache_default() { + TEST("Config enable_cache defaults to true"); + AgentLoop::Config cfg; + assert(cfg.enable_cache == true); + PASS(); +} + +void test_enable_compaction_default() { + TEST("Config enable_compaction defaults to true"); + AgentLoop::Config cfg; + assert(cfg.enable_compaction == true); + PASS(); +} + +void test_default_model_default() { + TEST("Config default_model defaults to gpt-4o"); + AgentLoop::Config cfg; + assert(cfg.default_model == "gpt-4o"); + PASS(); +} + +void test_max_output_tokens_default() { + TEST("Config max_output_tokens defaults to 4096"); + AgentLoop::Config cfg; + assert(cfg.max_output_tokens == 4096); + PASS(); +} + +void test_max_retries_default() { + TEST("Config max_retries defaults to 3"); + AgentLoop::Config cfg; + assert(cfg.max_retries == 3); + PASS(); +} + +void test_model_max_tokens_default() { + TEST("Config model_max_tokens defaults to 128000"); + AgentLoop::Config cfg; + assert(cfg.model_max_tokens == 128000); + PASS(); +} + +void test_initial_state_is_idle() { + TEST("initial state is Idle after construction"); + auto loop = make_test_loop(); + assert(loop->current_state() == TurnState::Idle); + PASS(); +} + +void test_session_history_initially_empty() { + TEST("session_history is empty after construction"); + auto loop = make_test_loop(); + assert(loop->session_history().empty()); + PASS(); +} + +void test_set_system_prompt_does_not_affect_session_history() { + TEST("set_system_prompt uses separate storage from session_history"); + AgentLoop::Config cfg; + cfg.system_prompt = "original"; + auto loop = make_test_loop(cfg); + loop->set_system_prompt("updated prompt"); + // system_prompt is separate from session_history + assert(loop->session_history().empty()); + PASS(); +} + +void test_restore_history_replaces_session() { + TEST("restore_history replaces existing session history"); + auto loop = make_test_loop(); + std::vector history = { + {"user", "hello", {}, {}, ""}, + {"assistant", "hi there", {}, {}, ""}, + }; + loop->restore_history(history); + assert(loop->session_history().size() == 2); + assert(loop->session_history()[0].role == "user"); + assert(loop->session_history()[0].content == "hello"); + assert(loop->session_history()[1].role == "assistant"); + assert(loop->session_history()[1].content == "hi there"); + PASS(); +} + +void test_restore_history_overwrites_previous() { + TEST("restore_history overwrites previously restored history"); + auto loop = make_test_loop(); + std::vector old_history = { + {"user", "old", {}, {}, ""}, + }; + std::vector new_history = { + {"user", "new", {}, {}, ""}, + {"assistant", "reply", {}, {}, ""}, + }; + loop->restore_history(old_history); + assert(loop->session_history().size() == 1); + loop->restore_history(new_history); + assert(loop->session_history().size() == 2); + assert(loop->session_history()[0].content == "new"); + PASS(); +} + +void test_tools_returns_registry() { + TEST("tools() returns the tool registry passed at construction"); + auto llm = std::make_shared(); + llm->canned.text = "ok"; + auto tools = std::make_shared(); + auto mem = std::make_shared( + MemoryConfig{}, + std::make_shared()); + auto counter = std::make_shared("gpt-4o"); + auto comp = std::make_shared(llm, counter, "gpt-4o"); + AgentLoop loop(AgentLoop::Config{}, llm, tools, mem, comp, + nullptr, nullptr); + assert(loop.tools() == tools); + PASS(); +} + +void test_pipeline_accessible() { + TEST("pipeline() returns a valid ContextPipeline reference"); + auto loop = make_test_loop(); + // Just verify the pipeline is accessible, not null + (void)loop->pipeline(); + PASS(); +} + +int main() { + std::cout << "\nAgentLoop Tests\n===============\n"; + test_max_turns_config_default(); + test_enable_cache_default(); + test_enable_compaction_default(); + test_default_model_default(); + test_max_output_tokens_default(); + test_max_retries_default(); + test_model_max_tokens_default(); + test_initial_state_is_idle(); + test_session_history_initially_empty(); + test_set_system_prompt_does_not_affect_session_history(); + test_restore_history_replaces_session(); + test_restore_history_overwrites_previous(); + test_tools_returns_registry(); + test_pipeline_accessible(); + std::cout << "\n" << tests_passed << "/" << tests_run << " passed\n"; + return tests_passed == tests_run ? 0 : 1; +} diff --git a/libs/loop/tests/test_sub_agent_runner.cpp b/libs/loop/tests/test_sub_agent_runner.cpp new file mode 100644 index 00000000..42a7ead6 --- /dev/null +++ b/libs/loop/tests/test_sub_agent_runner.cpp @@ -0,0 +1,170 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace merak; + +static int tests_run = 0; +static int tests_passed = 0; + +#define TEST(name) \ + tests_run++; \ + std::cout << " " << name << " ... " +#define PASS() \ + tests_passed++; \ + std::cout << "PASS" << std::endl + +// ——— Stubs ———————————————————————————————————————————————— + +// Minimal stub provider: simulates streaming via the on_chunk +// callback and returns a fixed AgentResponse. +class StubProvider : public LlmProvider { +public: + AgentResponse canned; + std::string name() const override { return "stub"; } + std::future chat( + const ChatRequest&, + std::function on_chunk, + std::shared_ptr) override + { + // Simulate streaming so the AgentLoop run_loop callback + // populates response.text with the canned text. + if (on_chunk) { + StreamChunk chunk; + chunk.text = canned.text; + on_chunk(chunk); + } + std::promise p; + auto future = p.get_future(); + p.set_value(canned); + return future; + } +}; + +// Minimal embedding provider stub so MemoryStore can be +// constructed without a real embedding service. +class StubEmbeddingProvider : public EmbeddingProvider { +public: + std::future> embed(const std::string&) override { + std::promise> p; + p.set_value({0.0f}); + return p.get_future(); + } + int dimension() const override { return 1; } + std::future>> embed_batch( + const std::vector&) override + { + std::promise>> p; + p.set_value({{0.0f}}); + return p.get_future(); + } +}; + +// Helper: build a valid SubAgentRunner with stub dependencies. +static std::unique_ptr make_test_runner() +{ + auto llm = std::make_shared(); + llm->canned.text = "ok"; + auto mem = std::make_shared( + MemoryConfig{}, + std::make_shared()); + auto tools = std::make_shared(); + return std::make_unique( + llm, mem, tools, nullptr, nullptr); +} + +// ——— Tests —————————————————————————————————————————————————— + +void test_has_agent_returns_false_initially() { + TEST("has_agent returns false before any registration"); + auto runner = make_test_runner(); + assert(!runner->has_agent("anything")); + PASS(); +} + +void test_has_agent_returns_true_after_register() { + TEST("has_agent returns true after register_profile"); + auto runner = make_test_runner(); + SubAgentConfig cfg; + cfg.id = "test_agent"; + cfg.system_prompt = "You are a test agent."; + runner->register_profile(cfg); + assert(runner->has_agent("test_agent")); + assert(!runner->has_agent("nonexistent")); + PASS(); +} + +void test_delegate_unknown_agent_returns_error() { + TEST("delegate to unknown agent returns error message"); + auto runner = make_test_runner(); + auto resp = runner->delegate("nonexistent", "do something").get(); + assert(!resp.text.empty()); + // The error message contains the agent id + assert(resp.text.find("nonexistent") != std::string::npos); + PASS(); +} + +void test_sequential_preserves_result_order() { + TEST("sequential preserves order of agent results"); + auto runner = make_test_runner(); + + // Register three agents + SubAgentConfig cfg; + cfg.id = "agent_a"; cfg.system_prompt = "test"; runner->register_profile(cfg); + cfg.id = "agent_b"; runner->register_profile(cfg); + cfg.id = "agent_c"; runner->register_profile(cfg); + + std::vector pipeline = { + {"agent_a", "task a"}, + {"agent_b", "task b"}, + {"agent_c", "task c"}, + }; + + auto resp = runner->sequential(pipeline).get(); + + // Each agent's result appears as "[agent_id]: ..." in the output. + auto pos_a = resp.text.find("[agent_a]"); + auto pos_b = resp.text.find("[agent_b]"); + auto pos_c = resp.text.find("[agent_c]"); + assert(pos_a != std::string::npos); + assert(pos_b != std::string::npos); + assert(pos_c != std::string::npos); + assert(pos_a < pos_b && pos_b < pos_c); + PASS(); +} + +void test_fan_out_returns_map_with_keys() { + TEST("fan_out returns a map keyed by agent id"); + auto runner = make_test_runner(); + + SubAgentConfig cfg; + cfg.id = "fan_a"; cfg.system_prompt = "test"; runner->register_profile(cfg); + cfg.id = "fan_b"; runner->register_profile(cfg); + + std::vector tasks = { + {"fan_a", "task a"}, + {"fan_b", "task b"}, + }; + + auto results = runner->fan_out(tasks).get(); + assert(results.size() == 2); + assert(results.count("fan_a") == 1); + assert(results.count("fan_b") == 1); + PASS(); +} + +int main() { + std::cout << "\nSubAgentRunner Tests\n====================\n"; + test_has_agent_returns_false_initially(); + test_has_agent_returns_true_after_register(); + test_delegate_unknown_agent_returns_error(); + test_sequential_preserves_result_order(); + test_fan_out_returns_map_with_keys(); + std::cout << "\n" << tests_passed << "/" << tests_run << " passed\n"; + return tests_passed == tests_run ? 0 : 1; +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2ff12193..e8494283 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -68,3 +68,31 @@ target_include_directories(merak-agent-endpoint-test PRIVATE ) target_link_libraries(merak-agent-endpoint-test PRIVATE merak-worldbuilding GTest::gtest_main) add_test(NAME merak-agent-endpoint-test COMMAND merak-agent-endpoint-test) + +# OpenAI provider tests +add_executable(merak-openai-provider-test + ${CMAKE_SOURCE_DIR}/libs/llm/tests/test_openai_provider.cpp +) +target_link_libraries(merak-openai-provider-test PRIVATE merak-llm) +add_test(NAME merak-openai-provider-test COMMAND merak-openai-provider-test) + +# Anthropic provider tests +add_executable(merak-anthropic-provider-test + ${CMAKE_SOURCE_DIR}/libs/llm/tests/test_anthropic_provider.cpp +) +target_link_libraries(merak-anthropic-provider-test PRIVATE merak-llm) +add_test(NAME merak-anthropic-provider-test COMMAND merak-anthropic-provider-test) + +# AgentLoop tests +add_executable(merak-agent-loop-test + ${CMAKE_SOURCE_DIR}/libs/loop/tests/test_agent_loop.cpp +) +target_link_libraries(merak-agent-loop-test PRIVATE merak-loop) +add_test(NAME merak-agent-loop-test COMMAND merak-agent-loop-test) + +# SubAgentRunner tests +add_executable(merak-sub-agent-runner-test + ${CMAKE_SOURCE_DIR}/libs/loop/tests/test_sub_agent_runner.cpp +) +target_link_libraries(merak-sub-agent-runner-test PRIVATE merak-loop) +add_test(NAME merak-sub-agent-runner-test COMMAND merak-sub-agent-runner-test)