BareGit

Implement full-history and rolling-window memory

Add the Memory interface and history strategies with message, character,
and approximate token budgets. Preserve tool-message pairing when trimming
and test snapshot isolation, budget boundaries, and history cleanup.
Author: MetroWind <chris.corsair@gmail.com>
Date: Mon Sep 21 16:10:23 2026 -0700
Commit: a1396e6c1d8a8fd98b204f863709425dd8254a7d

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