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