BareGit

Implement message model serialization

Add the OpenAI-compatible message model with strict assistant response
parsing. Cover JSON serialization and malformed payload handling.
Author: MetroWind <chris.corsair@gmail.com>
Date: Tue Jul 21 13:42:33 2026 -0700
Commit: 2015bd1e8015f0e06c13d9bf2b0897cde7c2b39d

Changes

diff --git a/CMakeLists.txt b/CMakeLists.txt
index 5c3b6c9..1130f30 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -73,6 +73,7 @@ add_library(agent_smith ${AGENT_SMITH_LIBRARY_TYPE}
     src/agent.cpp
     src/detail/blocking_pool.cpp
     src/detail/timer_service.cpp
+    src/message.cpp
     src/runtime.cpp
 )
 add_library(agent_smith::agent_smith ALIAS agent_smith)
diff --git a/include/agent_smith/agent_smith.hpp b/include/agent_smith/agent_smith.hpp
index ec21924..16391a6 100644
--- a/include/agent_smith/agent_smith.hpp
+++ b/include/agent_smith/agent_smith.hpp
@@ -2,5 +2,6 @@
 
 #include "agent.hpp"
 #include "error.hpp"
+#include "message.hpp"
 #include "runtime.hpp"
 #include "version.hpp"
diff --git a/include/agent_smith/error.hpp b/include/agent_smith/error.hpp
index 2905e2c..3493ed4 100644
--- a/include/agent_smith/error.hpp
+++ b/include/agent_smith/error.hpp
@@ -1,5 +1,9 @@
 #pragma once
 
+#include <cstddef>
+#include <string>
+#include <string_view>
+
 #include <mw/error.hpp>
 
 namespace agent_smith
@@ -8,4 +12,25 @@ namespace agent_smith
 /// The common result type for Agent Smith operations that can fail.
 template <typename T> using Result = mw::E<T>;
 
+/// Describes malformed data received from an external protocol payload.
+struct ParseError
+{
+    std::string msg;
+    std::size_t offset = 0;
+};
+
+namespace error
+{
+
+/// Create a typed parse error with an optional byte offset.
+inline mw::Error parse(std::string_view message, std::size_t offset = 0)
+{
+    return ParseError{
+        .msg = std::string(message),
+        .offset = offset,
+    };
+}
+
+} // namespace error
+
 } // namespace agent_smith
diff --git a/include/agent_smith/message.hpp b/include/agent_smith/message.hpp
new file mode 100644
index 0000000..e481ad8
--- /dev/null
+++ b/include/agent_smith/message.hpp
@@ -0,0 +1,80 @@
+#pragma once
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <variant>
+#include <vector>
+
+#include <nlohmann/json.hpp>
+
+#include "error.hpp"
+
+namespace agent_smith
+{
+
+/// The supported OpenAI-compatible conversation roles.
+enum class Role
+{
+    SYSTEM,
+    USER,
+    ASSISTANT,
+    TOOL,
+};
+
+/// Server-reported token counts for one completed LLM response.
+struct TokenUsage
+{
+    std::uint64_t prompt = 0;
+    std::uint64_t completion = 0;
+    std::uint64_t total = 0;
+};
+
+/// A system instruction supplied to the LLM.
+struct SystemMessage
+{
+    std::string content;
+};
+
+/// A user-authored text message.
+struct UserMessage
+{
+    std::string content;
+};
+
+/// One fully parsed function invocation requested by the LLM.
+struct ToolCall
+{
+    std::string id;
+    std::string name;
+    nlohmann::json arguments;
+};
+
+/// An LLM response that may contain text, tool calls, and token usage.
+struct AssistantMessage
+{
+    std::string content;
+    std::vector<ToolCall> tool_calls;
+    std::optional<TokenUsage> usage;
+};
+
+/// The result produced for one LLM tool invocation.
+struct ToolMessage
+{
+    std::string tool_call_id;
+    std::string name;
+    std::string content;
+    bool is_error = false;
+};
+
+/// One complete message in an Agent Smith conversation.
+using Message =
+    std::variant<SystemMessage, UserMessage, AssistantMessage, ToolMessage>;
+
+/// Convert a conversation message to its OpenAI-compatible wire representation.
+nlohmann::json toOpenAIJson(const Message& message);
+
+/// Parse an untrusted OpenAI-compatible assistant message payload.
+mw::E<Message> assistantMessageFromOpenAIJson(const nlohmann::json& json);
+
+} // namespace agent_smith
diff --git a/src/message.cpp b/src/message.cpp
new file mode 100644
index 0000000..e2a9b4d
--- /dev/null
+++ b/src/message.cpp
@@ -0,0 +1,257 @@
+#include "agent_smith/message.hpp"
+
+#include <cstdint>
+#include <string_view>
+#include <utility>
+
+namespace agent_smith
+{
+namespace
+{
+
+nlohmann::json toolCallToOpenAIJson(const ToolCall& tool_call)
+{
+    return {
+        {"id", tool_call.id},
+        {"type", "function"},
+        {"function",
+         {
+             {"name", tool_call.name},
+             {"arguments", tool_call.arguments.dump()},
+         }},
+    };
+}
+
+struct OpenAIJsonVisitor
+{
+    nlohmann::json operator()(const SystemMessage& message) const
+    {
+        return {
+            {"role", "system"},
+            {"content", message.content},
+        };
+    }
+
+    nlohmann::json operator()(const UserMessage& message) const
+    {
+        return {
+            {"role", "user"},
+            {"content", message.content},
+        };
+    }
+
+    nlohmann::json operator()(const AssistantMessage& message) const
+    {
+        nlohmann::json json{
+            {"role", "assistant"},
+            {"content", message.content},
+        };
+        if(!message.tool_calls.empty())
+        {
+            json["tool_calls"] = nlohmann::json::array();
+            for(const ToolCall& tool_call : message.tool_calls)
+            {
+                json["tool_calls"].push_back(toolCallToOpenAIJson(tool_call));
+            }
+        }
+        return json;
+    }
+
+    nlohmann::json operator()(const ToolMessage& message) const
+    {
+        return {
+            {"role", "tool"},
+            {"tool_call_id", message.tool_call_id},
+            {"name", message.name},
+            {"content", message.content},
+        };
+    }
+};
+
+mw::E<std::string> requiredString(const nlohmann::json& json,
+                                  std::string_view key)
+{
+    const auto found = json.find(key);
+    if(found == json.end() || !found->is_string())
+    {
+        return std::unexpected(
+            error::parse("expected string member: " + std::string(key)));
+    }
+    return found->get<std::string>();
+}
+
+mw::E<std::uint64_t> tokenCount(const nlohmann::json& json,
+                                std::string_view key)
+{
+    const auto found = json.find(key);
+    if(found == json.end())
+    {
+        return std::unexpected(
+            error::parse("expected unsigned token count: " + std::string(key)));
+    }
+
+    if(found->is_number_unsigned())
+    {
+        return found->get<std::uint64_t>();
+    }
+    if(found->is_number_integer())
+    {
+        const std::int64_t value = found->get<std::int64_t>();
+        if(value >= 0)
+        {
+            return static_cast<std::uint64_t>(value);
+        }
+    }
+
+    return std::unexpected(
+        error::parse("expected unsigned token count: " + std::string(key)));
+}
+
+mw::E<TokenUsage> usageFromOpenAIJson(const nlohmann::json& json)
+{
+    if(!json.is_object())
+    {
+        return std::unexpected(error::parse("expected usage object"));
+    }
+
+    auto prompt = tokenCount(json, "prompt_tokens");
+    if(!prompt)
+    {
+        return std::unexpected(prompt.error());
+    }
+    auto completion = tokenCount(json, "completion_tokens");
+    if(!completion)
+    {
+        return std::unexpected(completion.error());
+    }
+    auto total = tokenCount(json, "total_tokens");
+    if(!total)
+    {
+        return std::unexpected(total.error());
+    }
+
+    return TokenUsage{
+        .prompt = *prompt,
+        .completion = *completion,
+        .total = *total,
+    };
+}
+
+mw::E<ToolCall> toolCallFromOpenAIJson(const nlohmann::json& json)
+{
+    if(!json.is_object())
+    {
+        return std::unexpected(error::parse("expected tool call object"));
+    }
+
+    auto id = requiredString(json, "id");
+    if(!id)
+    {
+        return std::unexpected(id.error());
+    }
+
+    const auto type = json.find("type");
+    if(type == json.end() || !type->is_string() || *type != "function")
+    {
+        return std::unexpected(error::parse("expected function tool call"));
+    }
+
+    const auto function = json.find("function");
+    if(function == json.end() || !function->is_object())
+    {
+        return std::unexpected(error::parse("expected tool function object"));
+    }
+    auto name = requiredString(*function, "name");
+    if(!name)
+    {
+        return std::unexpected(name.error());
+    }
+    auto arguments = requiredString(*function, "arguments");
+    if(!arguments)
+    {
+        return std::unexpected(arguments.error());
+    }
+
+    nlohmann::json parsed_arguments =
+        nlohmann::json::parse(*arguments, nullptr, false);
+    if(parsed_arguments.is_discarded() || !parsed_arguments.is_object())
+    {
+        return std::unexpected(
+            error::parse("expected tool arguments JSON object"));
+    }
+
+    return ToolCall{
+        .id = std::move(*id),
+        .name = std::move(*name),
+        .arguments = std::move(parsed_arguments),
+    };
+}
+
+} // namespace
+
+nlohmann::json toOpenAIJson(const Message& message)
+{
+    return std::visit(OpenAIJsonVisitor{}, message);
+}
+
+mw::E<Message> assistantMessageFromOpenAIJson(const nlohmann::json& json)
+{
+    if(!json.is_object())
+    {
+        return std::unexpected(
+            error::parse("expected assistant message object"));
+    }
+
+    const auto role = json.find("role");
+    if(role == json.end() || !role->is_string() || *role != "assistant")
+    {
+        return std::unexpected(error::parse("expected assistant role"));
+    }
+
+    AssistantMessage message;
+    const auto content = json.find("content");
+    if(content != json.end() && !content->is_null())
+    {
+        if(!content->is_string())
+        {
+            return std::unexpected(
+                error::parse("expected assistant content string or null"));
+        }
+        message.content = content->get<std::string>();
+    }
+
+    const auto tool_calls = json.find("tool_calls");
+    if(tool_calls != json.end())
+    {
+        if(!tool_calls->is_array())
+        {
+            return std::unexpected(error::parse("expected tool_calls array"));
+        }
+
+        message.tool_calls.reserve(tool_calls->size());
+        for(const nlohmann::json& tool_call : *tool_calls)
+        {
+            auto parsed_tool_call = toolCallFromOpenAIJson(tool_call);
+            if(!parsed_tool_call)
+            {
+                return std::unexpected(parsed_tool_call.error());
+            }
+            message.tool_calls.push_back(std::move(*parsed_tool_call));
+        }
+    }
+
+    const auto usage = json.find("usage");
+    if(usage != json.end() && !usage->is_null())
+    {
+        auto parsed_usage = usageFromOpenAIJson(*usage);
+        if(!parsed_usage)
+        {
+            return std::unexpected(parsed_usage.error());
+        }
+        message.usage = std::move(*parsed_usage);
+    }
+
+    return Message(std::move(message));
+}
+
+} // namespace agent_smith
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 2645200..6e38f7b 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -1,4 +1,7 @@
-add_executable(agent_smith_test_skeleton test_skeleton.cpp)
+add_executable(agent_smith_test_skeleton
+    test_message.cpp
+    test_skeleton.cpp
+)
 target_link_libraries(agent_smith_test_skeleton
     PRIVATE
         agent_smith::agent_smith
diff --git a/tests/test_message.cpp b/tests/test_message.cpp
new file mode 100644
index 0000000..8d4c18d
--- /dev/null
+++ b/tests/test_message.cpp
@@ -0,0 +1,123 @@
+#include <agent_smith/message.hpp>
+#include <gtest/gtest.h>
+
+namespace agent_smith
+{
+namespace
+{
+
+TEST(MessageTest, SerializesAllMessageKinds)
+{
+    EXPECT_EQ(
+        toOpenAIJson(Message(SystemMessage{"instructions"})),
+        nlohmann::json({{"role", "system"}, {"content", "instructions"}}));
+    EXPECT_EQ(toOpenAIJson(Message(UserMessage{"hello"})),
+              nlohmann::json({{"role", "user"}, {"content", "hello"}}));
+
+    ToolMessage tool{
+        .tool_call_id = "call_1",
+        .name = "weather",
+        .content = "sunny",
+        .is_error = true,
+    };
+    EXPECT_EQ(toOpenAIJson(Message(tool)),
+              nlohmann::json({{"role", "tool"},
+                              {"tool_call_id", "call_1"},
+                              {"name", "weather"},
+                              {"content", "sunny"}}));
+}
+
+TEST(MessageTest, SerializesAssistantToolCalls)
+{
+    AssistantMessage assistant{
+        .content = "",
+        .tool_calls =
+            {
+                ToolCall{
+                    .id = "call_1",
+                    .name = "weather",
+                    .arguments = {{"city", "Portland"}},
+                },
+            },
+    };
+
+    const nlohmann::json json = toOpenAIJson(Message(assistant));
+
+    EXPECT_EQ(json.at("role"), "assistant");
+    EXPECT_EQ(json.at("content"), "");
+    ASSERT_TRUE(json.contains("tool_calls"));
+    ASSERT_EQ(json.at("tool_calls").size(), 1U);
+    EXPECT_EQ(json.at("tool_calls")[0],
+              nlohmann::json({{"id", "call_1"},
+                              {"type", "function"},
+                              {"function",
+                               {{"name", "weather"},
+                                {"arguments", "{\"city\":\"Portland\"}"}}}}));
+}
+
+TEST(MessageTest, ParsesAssistantMessageWithToolCallsAndUsage)
+{
+    const nlohmann::json json{
+        {"role", "assistant"},
+        {"content", nullptr},
+        {"tool_calls",
+         {{{"id", "call_1"},
+           {"type", "function"},
+           {"function",
+            {{"name", "weather"}, {"arguments", "{\"city\":\"Portland\"}"}}}}}},
+        {"usage",
+         {{"prompt_tokens", 10},
+          {"completion_tokens", 4},
+          {"total_tokens", 14}}},
+    };
+
+    auto parsed = assistantMessageFromOpenAIJson(json);
+
+    ASSERT_TRUE(parsed.has_value());
+    const auto* message = std::get_if<AssistantMessage>(&*parsed);
+    ASSERT_NE(message, nullptr);
+    EXPECT_EQ(message->content, "");
+    ASSERT_EQ(message->tool_calls.size(), 1U);
+    EXPECT_EQ(message->tool_calls[0].id, "call_1");
+    EXPECT_EQ(message->tool_calls[0].name, "weather");
+    EXPECT_EQ(message->tool_calls[0].arguments,
+              nlohmann::json({{"city", "Portland"}}));
+    ASSERT_TRUE(message->usage.has_value());
+    EXPECT_EQ(message->usage->prompt, 10U);
+    EXPECT_EQ(message->usage->completion, 4U);
+    EXPECT_EQ(message->usage->total, 14U);
+}
+
+TEST(MessageTest, RejectsMalformedAssistantToolArguments)
+{
+    const nlohmann::json json{
+        {"role", "assistant"},
+        {"tool_calls",
+         {{{"id", "call_1"},
+           {"type", "function"},
+           {"function", {{"name", "weather"}, {"arguments", "not JSON"}}}}}},
+    };
+
+    auto parsed = assistantMessageFromOpenAIJson(json);
+
+    ASSERT_FALSE(parsed.has_value());
+    const ParseError* error = parsed.error().as<ParseError>();
+    ASSERT_NE(error, nullptr);
+    EXPECT_EQ(error->msg, "expected tool arguments JSON object");
+}
+
+TEST(MessageTest, RejectsUnexpectedAssistantContent)
+{
+    const nlohmann::json json{
+        {"role", "assistant"},
+        {"content", {"unexpected", "object"}},
+    };
+
+    auto parsed = assistantMessageFromOpenAIJson(json);
+
+    ASSERT_FALSE(parsed.has_value());
+    EXPECT_NE(parsed.error().as<ParseError>(), nullptr);
+}
+
+} // namespace
+} // namespace agent_smith