Changes
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 89cde78..ea8fc1a 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -76,6 +76,7 @@ add_library(agent_smith ${AGENT_SMITH_LIBRARY_TYPE}
src/detail/tool_dispatch.cpp
src/llm_client.cpp
src/message.cpp
+ src/memory.cpp
src/runtime.cpp
src/sse_parser.cpp
src/tool.cpp
diff --git a/designs/design-0-framework.md b/designs/design-0-framework.md
index 37fc812..d529695 100644
--- a/designs/design-0-framework.md
+++ b/designs/design-0-framework.md
@@ -1743,6 +1743,16 @@ public:
simple. Failure to summarize falls back to rolling-window
truncation so the loop never stalls.
+The rolling-window constructor accepts `RollingWindowConfig` with optional
+`max_messages`, `max_chars`, and `max_tokens` limits; all configured limits
+apply. A message-count convenience constructor is also available. Budgets
+apply to stored history, excluding the separately supplied base system
+prompt. Character accounting uses UTF-8 bytes of each serialized OpenAI
+message, including tool arguments and metadata; token estimates allow four
+such bytes per token. Trimming occurs on append, drops whole messages, and
+discards a message that exceeds the entire budget. `size()` reports retained
+messages, and `snapshot()` returns an independent copy without changing them.
+
### 13.3 Tool-message pairing invariant
A `ToolMessage` MUST always be preceded (eventually, with no
diff --git a/include/agent_smith/agent_smith.hpp b/include/agent_smith/agent_smith.hpp
index 76b7701..7f3a85f 100644
--- a/include/agent_smith/agent_smith.hpp
+++ b/include/agent_smith/agent_smith.hpp
@@ -5,6 +5,7 @@
#include "error.hpp"
#include "llm_client.hpp"
#include "message.hpp"
+#include "memory.hpp"
#include "runtime.hpp"
#include "stream_event.hpp"
#include "sync_wait.hpp"
diff --git a/include/agent_smith/memory.hpp b/include/agent_smith/memory.hpp
new file mode 100644
index 0000000..7fa68ac
--- /dev/null
+++ b/include/agent_smith/memory.hpp
@@ -0,0 +1,88 @@
+#pragma once
+
+#include <cstddef>
+#include <optional>
+#include <vector>
+
+#include <mw/task.hpp>
+
+#include "message.hpp"
+
+namespace agent_smith
+{
+
+/// In-process conversation storage. Access from one agent at a time.
+class Memory
+{
+public:
+ /// Destroy a memory strategy through its interface.
+ virtual ~Memory() = default;
+ /// Record a message in conversation order.
+ virtual void append(Message message) = 0;
+ /// Copy history with the supplied system message prepended.
+ /// Keep base_system alive until the returned task completes.
+ virtual mw::Task<mw::E<std::vector<Message>>>
+ snapshot(const SystemMessage& base_system) = 0;
+ /// Return the retained message count, excluding the supplied system prompt.
+ virtual std::size_t size() const = 0;
+ /// Remove all retained messages.
+ virtual void clear() = 0;
+ /// Remove tool results without a matching call in the latest assistant
+ /// turn. Each call ID can match only one result; order is preserved.
+ static void stripOrphans(std::vector<Message>& messages);
+};
+
+/// Retains every appended message without truncation or rewriting.
+class FullMemory final : public Memory
+{
+public:
+ /// Append a message without imposing a budget.
+ void append(Message message) override;
+ /// Return an independent copy of the full history with the system prompt.
+ mw::Task<mw::E<std::vector<Message>>>
+ snapshot(const SystemMessage& base_system) override;
+ /// Return the full history's message count.
+ std::size_t size() const override;
+ /// Discard the full history.
+ void clear() override;
+private:
+ std::vector<Message> messages_;
+};
+
+/// Limits on retained history; every configured limit applies.
+struct RollingWindowConfig
+{
+ /// Maximum message count; absent means unlimited.
+ std::optional<std::size_t> max_messages = std::nullopt;
+ /// Maximum UTF-8 byte count of serialized OpenAI messages (no separators).
+ /// This conservative character estimate includes tool names and arguments.
+ std::optional<std::size_t> max_chars = std::nullopt;
+ /// Approximate token budget, converted to max_chars at four bytes/token.
+ std::optional<std::size_t> max_tokens = std::nullopt;
+};
+
+/// Keeps recent history by dropping whole oldest messages on append.
+/// Orphaned tool results are also discarded. Limits exclude the supplied
+/// system prompt. A message larger than the entire budget is discarded.
+class RollingWindowMemory final : public Memory
+{
+public:
+ /// Construct a window constrained by all supplied limits (none: unlimited).
+ explicit RollingWindowMemory(RollingWindowConfig config);
+ /// Construct a window limited to the most recent max_messages messages.
+ explicit RollingWindowMemory(std::size_t max_messages);
+ /// Append a message, enforce budgets, and remove orphaned tool results.
+ void append(Message message) override;
+ /// Copy retained history and prepend the unbudgeted system prompt.
+ mw::Task<mw::E<std::vector<Message>>>
+ snapshot(const SystemMessage& base_system) override;
+ /// Return the retained history's message count.
+ std::size_t size() const override;
+ /// Discard retained history while preserving configured limits.
+ void clear() override;
+private:
+ RollingWindowConfig config_;
+ std::vector<Message> messages_;
+};
+
+} // namespace agent_smith
diff --git a/src/memory.cpp b/src/memory.cpp
new file mode 100644
index 0000000..bab39af
--- /dev/null
+++ b/src/memory.cpp
@@ -0,0 +1,108 @@
+#include "agent_smith/memory.hpp"
+
+#include <algorithm>
+#include <limits>
+#include <unordered_set>
+#include <utility>
+
+namespace agent_smith
+{
+namespace
+{
+
+std::vector<Message> withSystem(const std::vector<Message>& messages,
+ const SystemMessage& system)
+{
+ std::vector<Message> result;
+ result.reserve(messages.size() + 1);
+ result.emplace_back(system);
+ result.insert(result.end(), messages.begin(), messages.end());
+ return result;
+}
+
+} // namespace
+
+void Memory::stripOrphans(std::vector<Message>& messages)
+{
+ std::unordered_set<std::string> pending;
+ std::erase_if(messages, [&pending](const Message& message)
+ {
+ if(const auto* assistant = std::get_if<AssistantMessage>(&message))
+ {
+ pending.clear();
+ for(const auto& call : assistant->tool_calls)
+ {
+ pending.insert(call.id);
+ }
+ }
+ else if(const auto* tool = std::get_if<ToolMessage>(&message))
+ {
+ return pending.erase(tool->tool_call_id) == 0;
+ }
+ return false;
+ });
+}
+
+void FullMemory::append(Message message)
+{
+ messages_.push_back(std::move(message));
+}
+
+mw::Task<mw::E<std::vector<Message>>>
+FullMemory::snapshot(const SystemMessage& base_system)
+{
+ co_return withSystem(messages_, base_system);
+}
+
+std::size_t FullMemory::size() const { return messages_.size(); }
+void FullMemory::clear() { messages_.clear(); }
+
+RollingWindowMemory::RollingWindowMemory(RollingWindowConfig config)
+ : config_(std::move(config))
+{
+ if(config_.max_tokens)
+ {
+ constexpr auto MAX_SIZE = std::numeric_limits<std::size_t>::max();
+ const auto chars = *config_.max_tokens > MAX_SIZE / 4
+ ? MAX_SIZE : *config_.max_tokens * 4;
+ config_.max_chars = std::min(config_.max_chars.value_or(MAX_SIZE), chars);
+ }
+}
+
+RollingWindowMemory::RollingWindowMemory(std::size_t max_messages)
+ : RollingWindowMemory(RollingWindowConfig{.max_messages = max_messages})
+{}
+
+void RollingWindowMemory::append(Message message)
+{
+ // Compute the retained suffix backwards, avoiding size-sum overflow.
+ messages_.push_back(std::move(message));
+ auto remaining = config_.max_chars.value_or(
+ std::numeric_limits<std::size_t>::max());
+ const auto count = config_.max_messages.value_or(messages_.size());
+ auto begin = messages_.size();
+ while(begin > 0 && messages_.size() - begin < count)
+ {
+ const auto bytes = config_.max_chars
+ ? toOpenAIJson(messages_[begin - 1]).dump().size() : 0;
+ if(bytes > remaining)
+ {
+ break;
+ }
+ remaining -= bytes;
+ --begin;
+ }
+ messages_.erase(messages_.begin(), messages_.begin() + begin);
+ stripOrphans(messages_);
+}
+
+mw::Task<mw::E<std::vector<Message>>>
+RollingWindowMemory::snapshot(const SystemMessage& base_system)
+{
+ co_return withSystem(messages_, base_system);
+}
+
+std::size_t RollingWindowMemory::size() const { return messages_.size(); }
+void RollingWindowMemory::clear() { messages_.clear(); }
+
+} // namespace agent_smith
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 7a52c86..ef536fe 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -1,6 +1,7 @@
add_executable(agent_smith_test_skeleton
test_llm_client.cpp
test_message.cpp
+ test_memory.cpp
test_sse_parser.cpp
test_skeleton.cpp
test_tool.cpp
diff --git a/tests/test_memory.cpp b/tests/test_memory.cpp
new file mode 100644
index 0000000..49f1acc
--- /dev/null
+++ b/tests/test_memory.cpp
@@ -0,0 +1,169 @@
+#include <limits>
+
+#include <agent_smith/memory.hpp>
+#include <agent_smith/sync_wait.hpp>
+#include <gtest/gtest.h>
+
+namespace agent_smith
+{
+namespace
+{
+
+std::vector<Message> snapshot(Memory& memory)
+{
+ const SystemMessage system{"system"};
+ auto result = syncWait(memory.snapshot(system));
+ EXPECT_TRUE(result);
+ return result ? std::move(*result) : std::vector<Message>{};
+}
+
+AssistantMessage calls()
+{
+ return {.content = "calling",
+ .tool_calls = {{"a", "weather", {{"city", "Tokyo"}}},
+ {"b", "weather", {{"city", "Paris"}}}}};
+}
+
+TEST(FullMemoryTest, PreservesHistoryAndCopiesSnapshots)
+{
+ FullMemory memory;
+ EXPECT_EQ(snapshot(memory).size(), 1U);
+ memory.append(UserMessage{"hi"});
+ memory.append(calls());
+ memory.append(ToolMessage{"a", "weather", "sunny"});
+ memory.append(ToolMessage{"b", "weather", "rainy"});
+ memory.append(AssistantMessage{.content = "done"});
+ ASSERT_EQ(memory.size(), 5U);
+ auto first = snapshot(memory);
+ ASSERT_EQ(first.size(), 6U);
+ EXPECT_EQ(std::get<SystemMessage>(first[0]).content, "system");
+ EXPECT_EQ(std::get<AssistantMessage>(first[2]).tool_calls.size(), 2U);
+ std::get<UserMessage>(first[1]).content = "changed";
+ EXPECT_EQ(std::get<UserMessage>(snapshot(memory)[1]).content, "hi");
+ memory.clear();
+ EXPECT_EQ(memory.size(), 0U);
+ EXPECT_EQ(first.size(), 6U);
+}
+
+TEST(RollingWindowMemoryTest, RetainsNewestAndKeepsSystemOutsideBudget)
+{
+ RollingWindowMemory memory(2);
+ memory.append(UserMessage{"old"});
+ memory.append(AssistantMessage{.content = "answer"});
+ memory.append(UserMessage{"new"});
+ auto result = snapshot(memory);
+ ASSERT_EQ(result.size(), 3U);
+ EXPECT_EQ(std::get<AssistantMessage>(result[1]).content, "answer");
+ EXPECT_EQ(std::get<UserMessage>(result[2]).content, "new");
+ memory.clear();
+ EXPECT_EQ(memory.size(), 0U);
+ memory.append(UserMessage{"again"});
+ EXPECT_EQ(memory.size(), 1U);
+}
+
+TEST(RollingWindowMemoryTest, AppliesCharacterAndTokenBudgets)
+{
+ const Message message = UserMessage{"hello"};
+ const auto bytes = toOpenAIJson(message).dump().size();
+ RollingWindowMemory exact({.max_chars = bytes});
+ exact.append(message);
+ EXPECT_EQ(exact.size(), 1U);
+ exact.append(message);
+ EXPECT_EQ(exact.size(), 1U);
+ RollingWindowMemory too_small({.max_chars = bytes - 1});
+ too_small.append(message);
+ EXPECT_EQ(too_small.size(), 0U);
+ RollingWindowMemory tokens({.max_tokens = (bytes + 3) / 4});
+ tokens.append(message);
+ EXPECT_EQ(tokens.size(), 1U);
+ tokens.append(message);
+ EXPECT_EQ(tokens.size(), 1U);
+ RollingWindowMemory both({.max_messages = 0, .max_tokens = 100});
+ both.append(message);
+ EXPECT_EQ(snapshot(both).size(), 1U);
+ RollingWindowMemory huge({.max_tokens =
+ std::numeric_limits<std::size_t>::max()});
+ huge.append(message);
+ EXPECT_EQ(huge.size(), 1U);
+}
+
+TEST(RollingWindowMemoryTest, CountsToolArgumentsAndDropsEntireGroup)
+{
+ RollingWindowMemory memory(3);
+ memory.append(calls());
+ memory.append(ToolMessage{"a", "weather", "sunny"});
+ memory.append(ToolMessage{"b", "weather", "rainy"});
+ EXPECT_EQ(memory.size(), 3U);
+ memory.append(UserMessage{"next"});
+ ASSERT_EQ(memory.size(), 1U);
+ EXPECT_EQ(std::get<UserMessage>(snapshot(memory)[1]).content, "next");
+ RollingWindowMemory small({.max_chars = 100});
+ auto large = calls();
+ large.tool_calls[0].arguments = {{"data", std::string(1000, 'x')}};
+ small.append(large);
+ small.append(ToolMessage{"a", "weather", "result"});
+ EXPECT_EQ(small.size(), 0U);
+}
+
+TEST(MemoryTest, RemovesOrphansDuplicatesAndCallsFromPreviousAssistant)
+{
+ std::vector<Message> messages{
+ ToolMessage{"a", "weather", "orphan"}, calls(),
+ ToolMessage{"b", "weather", "second first"},
+ ToolMessage{"a", "weather", "first second"},
+ ToolMessage{"a", "weather", "duplicate"},
+ AssistantMessage{.content = "done"},
+ ToolMessage{"b", "weather", "stale"}};
+ Memory::stripOrphans(messages);
+ ASSERT_EQ(messages.size(), 4U);
+ EXPECT_EQ(std::get<ToolMessage>(messages[1]).tool_call_id, "b");
+ EXPECT_EQ(std::get<ToolMessage>(messages[2]).tool_call_id, "a");
+ Memory::stripOrphans(messages);
+ EXPECT_EQ(messages.size(), 4U);
+}
+
+TEST(RollingWindowMemoryTest, CombinedLimitsAndOversizeMessages)
+{
+ const Message message = UserMessage{"hello"};
+ const auto bytes = toOpenAIJson(message).dump().size();
+ RollingWindowMemory memory({.max_messages = 3, .max_chars = bytes * 2,
+ .max_tokens = 1000});
+ for(int index = 0; index < 4; ++index)
+ {
+ memory.append(message);
+ }
+ EXPECT_EQ(memory.size(), 2U);
+ memory.append(UserMessage{std::string(1000, 'x')});
+ EXPECT_EQ(memory.size(), 0U);
+ memory.append(message);
+ EXPECT_EQ(memory.size(), 1U);
+ RollingWindowMemory zero({.max_tokens = 0});
+ zero.append(UserMessage{""});
+ EXPECT_EQ(zero.size(), 0U);
+}
+
+TEST(MemoryTest, ChangingSystemPromptDoesNotAccumulateHistory)
+{
+ FullMemory full;
+ RollingWindowMemory rolling(4);
+ for(Memory* memory : std::vector<Memory*>{&full, &rolling})
+ {
+ memory->append(SystemMessage{"stored instruction"});
+ memory->append(UserMessage{"你好"});
+ const SystemMessage first{"first"};
+ const SystemMessage second{"second"};
+ auto original = syncWait(memory->snapshot(first));
+ auto updated = syncWait(memory->snapshot(second));
+ ASSERT_TRUE(original);
+ ASSERT_TRUE(updated);
+ ASSERT_EQ(updated->size(), 3U);
+ EXPECT_EQ(std::get<SystemMessage>((*original)[0]).content, "first");
+ EXPECT_EQ(std::get<SystemMessage>((*updated)[0]).content, "second");
+ EXPECT_EQ(std::get<SystemMessage>((*updated)[1]).content,
+ "stored instruction");
+ EXPECT_EQ(memory->size(), 2U);
+ }
+}
+
+} // namespace
+} // namespace agent_smith