From 7e728b05aaf1933e79768b56bc1fed5f5db3e03b Mon Sep 17 00:00:00 2001 From: ULookup Date: Sat, 20 Jun 2026 05:09:56 +0000 Subject: [PATCH 01/12] feat(llm): add exponential backoff retry to provider HTTP calls --- libs/llm/include/merak/llm_provider.hpp | 5 + libs/llm/src/anthropic_provider.cpp | 128 ++++++++++++++---------- libs/llm/src/openai_provider.cpp | 120 +++++++++++++--------- 3 files changed, 151 insertions(+), 102 deletions(-) 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/src/anthropic_provider.cpp b/libs/llm/src/anthropic_provider.cpp index 48734f04..3ef214bc 100644 --- a/libs/llm/src/anthropic_provider.cpp +++ b/libs/llm/src/anthropic_provider.cpp @@ -2,6 +2,7 @@ #include #include #include +#include namespace merak { @@ -130,28 +131,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 +258,94 @@ 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(); + 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) + ")"); } - throw AgentError(ErrorType::LLM_ERROR, - "LLM API error (HTTP " + std::to_string(http_code) + ")"); - } - curl_easy_cleanup(curl); - curl_slist_free_all(headers); + // 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) + ")"); + } + + // 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..f572a162 100644 --- a/libs/llm/src/openai_provider.cpp +++ b/libs/llm/src/openai_provider.cpp @@ -2,6 +2,7 @@ #include #include #include +#include namespace merak { @@ -35,26 +36,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 +116,88 @@ 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(); + 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) + ")"); } - throw AgentError(ErrorType::LLM_ERROR, - "LLM API error (HTTP " + std::to_string(http_code) + ")"); - } - curl_easy_cleanup(curl); - curl_slist_free_all(headers); + // 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) + ")"); + } + + // 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) { From 3559edf48fcec1a01981ba3797ee69ff848c2224 Mon Sep 17 00:00:00 2001 From: ULookup Date: Sat, 20 Jun 2026 05:14:20 +0000 Subject: [PATCH 02/12] fix(llm): add curl_easy_init NULL check and missing include --- libs/llm/src/anthropic_provider.cpp | 13 +++++++++++++ libs/llm/src/openai_provider.cpp | 13 +++++++++++++ 2 files changed, 26 insertions(+) diff --git a/libs/llm/src/anthropic_provider.cpp b/libs/llm/src/anthropic_provider.cpp index 3ef214bc..5d2a85a2 100644 --- a/libs/llm/src/anthropic_provider.cpp +++ b/libs/llm/src/anthropic_provider.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include namespace merak { @@ -276,6 +277,18 @@ std::future AnthropicProvider::chat( line_buffer.clear(); 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()); diff --git a/libs/llm/src/openai_provider.cpp b/libs/llm/src/openai_provider.cpp index f572a162..08b1978c 100644 --- a/libs/llm/src/openai_provider.cpp +++ b/libs/llm/src/openai_provider.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include namespace merak { @@ -130,6 +131,18 @@ std::future OpenAIProvider::chat( line_buffer.clear(); 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()); From 45eb3e6c5ce1a12e11d4027fd223d8062d35d500 Mon Sep 17 00:00:00 2001 From: ULookup Date: Sat, 20 Jun 2026 05:15:31 +0000 Subject: [PATCH 03/12] feat(loop): apply CacheAwareContext split result to request messages --- libs/loop/src/agent_loop.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/libs/loop/src/agent_loop.cpp b/libs/loop/src/agent_loop.cpp index ebb297d5..261d970e 100644 --- a/libs/loop/src/agent_loop.cpp +++ b/libs/loop/src/agent_loop.cpp @@ -103,8 +103,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); From 7e6c71fcce58db81724ba68609617fc60a53ca58 Mon Sep 17 00:00:00 2001 From: ULookup Date: Sat, 20 Jun 2026 05:18:56 +0000 Subject: [PATCH 04/12] fix(loop): separate system prompt from session_history to prevent compaction overwrite --- libs/loop/include/merak/agent_loop.hpp | 2 ++ libs/loop/src/agent_loop.cpp | 23 +++++++++++++++-------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/libs/loop/include/merak/agent_loop.hpp b/libs/loop/include/merak/agent_loop.hpp index df4ff2ea..9d6586ed 100644 --- a/libs/loop/include/merak/agent_loop.hpp +++ b/libs/loop/include/merak/agent_loop.hpp @@ -95,6 +95,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 261d970e..c6970fec 100644 --- a/libs/loop/src/agent_loop.cpp +++ b/libs/loop/src/agent_loop.cpp @@ -34,11 +34,7 @@ void AgentLoop::restore_history(std::vector history) { } 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) { @@ -501,11 +497,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( @@ -657,7 +664,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())); } } From 7bcd63cb69a7c55bc332785ce926956506905348 Mon Sep 17 00:00:00 2001 From: ULookup Date: Sat, 20 Jun 2026 05:23:41 +0000 Subject: [PATCH 05/12] fix(loop): clear compaction_summaries on restore and update stale comment --- libs/loop/include/merak/agent_loop.hpp | 2 +- libs/loop/src/agent_loop.cpp | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/libs/loop/include/merak/agent_loop.hpp b/libs/loop/include/merak/agent_loop.hpp index 9d6586ed..36ba41a8 100644 --- a/libs/loop/include/merak/agent_loop.hpp +++ b/libs/loop/include/merak/agent_loop.hpp @@ -50,7 +50,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. diff --git a/libs/loop/src/agent_loop.cpp b/libs/loop/src/agent_loop.cpp index c6970fec..367ec3d7 100644 --- a/libs/loop/src/agent_loop.cpp +++ b/libs/loop/src/agent_loop.cpp @@ -31,6 +31,7 @@ AgentLoop::AgentLoop( void AgentLoop::restore_history(std::vector history) { session_history_ = std::move(history); + compaction_summaries_.clear(); } void AgentLoop::set_system_prompt(const std::string& prompt) { From 0ec199fa4209c889b1f33c6d16bd6797bcab4a4f Mon Sep 17 00:00:00 2001 From: ULookup Date: Sat, 20 Jun 2026 05:28:18 +0000 Subject: [PATCH 06/12] feat(context): hybrid token counting with API-authoritative baseline --- libs/context/include/merak/token_counter.hpp | 10 ++++++++++ libs/context/src/token_counter.cpp | 9 +++++++++ libs/loop/include/merak/agent_loop.hpp | 2 ++ libs/loop/src/agent_loop.cpp | 7 +++++-- 4 files changed, 26 insertions(+), 2 deletions(-) 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/loop/include/merak/agent_loop.hpp b/libs/loop/include/merak/agent_loop.hpp index 36ba41a8..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 @@ -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_; diff --git a/libs/loop/src/agent_loop.cpp b/libs/loop/src/agent_loop.cpp index 367ec3d7..0f2d6d72 100644 --- a/libs/loop/src/agent_loop.cpp +++ b/libs/loop/src/agent_loop.cpp @@ -25,6 +25,7 @@ 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_); } @@ -212,6 +213,9 @@ AgentResponse AgentLoop::run_loop(RunControl& control) { 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"); @@ -652,8 +656,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. From ca49d8d1965f297e50ff0adb177c5fe0e4e83784 Mon Sep 17 00:00:00 2001 From: ULookup Date: Sat, 20 Jun 2026 05:31:08 +0000 Subject: [PATCH 07/12] fix(loop): reset token counter authoritative state on history restore --- libs/loop/src/agent_loop.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/loop/src/agent_loop.cpp b/libs/loop/src/agent_loop.cpp index 0f2d6d72..e9454cc9 100644 --- a/libs/loop/src/agent_loop.cpp +++ b/libs/loop/src/agent_loop.cpp @@ -33,6 +33,7 @@ AgentLoop::AgentLoop( 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) { From a83f293d2178a81b6258894b19cebade6e3828c0 Mon Sep 17 00:00:00 2001 From: ULookup Date: Sat, 20 Jun 2026 05:31:50 +0000 Subject: [PATCH 08/12] fix(loop): cap fan_out parallelism at min(4, hardware_concurrency) --- libs/loop/src/sub_agent_runner.cpp | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) 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()); From 5af5f5e284b4b755d41e9263936aaa3ee06245d3 Mon Sep 17 00:00:00 2001 From: ULookup Date: Sat, 20 Jun 2026 05:35:48 +0000 Subject: [PATCH 09/12] test(llm): add unit tests for OpenAI and Anthropic provider request builders --- libs/llm/include/merak/anthropic_provider.hpp | 4 +- libs/llm/include/merak/openai_provider.hpp | 6 +- libs/llm/tests/test_anthropic_provider.cpp | 159 ++++++++++++++++++ libs/llm/tests/test_openai_provider.cpp | 109 ++++++++++++ tests/CMakeLists.txt | 14 ++ 5 files changed, 287 insertions(+), 5 deletions(-) create mode 100644 libs/llm/tests/test_anthropic_provider.cpp create mode 100644 libs/llm/tests/test_openai_provider.cpp 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/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/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/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2ff12193..10416fe4 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -68,3 +68,17 @@ 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) From 9a50d714a1ac535401999a9d90c08705bbf9c79a Mon Sep 17 00:00:00 2001 From: ULookup Date: Sat, 20 Jun 2026 05:41:11 +0000 Subject: [PATCH 10/12] test(loop): add unit tests for AgentLoop and SubAgentRunner Includes 14 tests covering Config defaults, state management, history restore, agent registration, delegation error handling, and sequential pipeline ordering. Uses stub provider classes to avoid real LLM calls. --- libs/loop/tests/test_agent_loop.cpp | 238 ++++++++++++++++++++++ libs/loop/tests/test_sub_agent_runner.cpp | 170 ++++++++++++++++ tests/CMakeLists.txt | 14 ++ 3 files changed, 422 insertions(+) create mode 100644 libs/loop/tests/test_agent_loop.cpp create mode 100644 libs/loop/tests/test_sub_agent_runner.cpp 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 10416fe4..e8494283 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -82,3 +82,17 @@ add_executable(merak-anthropic-provider-test ) 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) From 3772e455a957f5d0e6b2db9538b5ce3ee6465fd9 Mon Sep 17 00:00:00 2001 From: ULookup Date: Sun, 21 Jun 2026 04:15:31 +0000 Subject: [PATCH 11/12] fix(loop): remove AgentLoop retry layer, rely on provider internal retry Provider layer (openai_provider.cpp, anthropic_provider.cpp) already handles all transient failures with 3 attempts and exponential backoff. The AgentLoop retry was redundant and introduced two bugs: 1. Duplicate stream chunks: the failed attempt already emitted partial text via the on_chunk callback (flushed to UI). The retry re-emitted the same content from the start. 2. Response.text corruption: on final failure, response.text contained debris from the last failed attempt. --- libs/loop/src/agent_loop.cpp | 71 ++++-------------------------------- 1 file changed, 8 insertions(+), 63 deletions(-) diff --git a/libs/loop/src/agent_loop.cpp b/libs/loop/src/agent_loop.cpp index e9454cc9..4ce3a6f3 100644 --- a/libs/loop/src/agent_loop.cpp +++ b/libs/loop/src/agent_loop.cpp @@ -134,10 +134,9 @@ 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(); + // Provider layer handles all retry internally (exponential backoff). + // AgentLoop only catches to handle context-window recovery. + AgentResponse llm_response; auto llm_future = llm_->chat(req, [&](StreamChunk chunk) { @@ -150,65 +149,11 @@ 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; From 76244259f984323d725c1d394083127598b92a76 Mon Sep 17 00:00:00 2001 From: ULookup Date: Sun, 21 Jun 2026 04:26:30 +0000 Subject: [PATCH 12/12] fix(loop): remove all AgentLoop retry logic, delegate entirely to provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provider layer handles all transient errors (5xx, 429, curl errors) with 3 attempts and exponential backoff. AgentLoop now only catches for logging before propagating the exception upstream. Context-window retry was also removed: all callers (fork_skill_tool, worldbuilding_tools) already catch exceptions from sub_loop.run() and return errors as tool results. The heuristic-based compact+retry was unreliable — if token counting underestimates, compaction won't help either. --- libs/loop/src/agent_loop.cpp | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/libs/loop/src/agent_loop.cpp b/libs/loop/src/agent_loop.cpp index 4ce3a6f3..dabc9ba5 100644 --- a/libs/loop/src/agent_loop.cpp +++ b/libs/loop/src/agent_loop.cpp @@ -134,10 +134,6 @@ AgentResponse AgentLoop::run_loop(RunControl& control) { std::vector accumulated_tool_calls; - // Provider layer handles all retry internally (exponential backoff). - // AgentLoop only catches to handle context-window recovery. - AgentResponse llm_response; - auto llm_future = llm_->chat(req, [&](StreamChunk chunk) { auto token = control.cancellation_token(); @@ -149,6 +145,7 @@ AgentResponse AgentLoop::run_loop(RunControl& control) { } }, control.cancellation_token()); + AgentResponse llm_response; try { llm_response = llm_future.get(); } catch (const std::exception& e) {