diff --git a/CMakeLists.txt b/CMakeLists.txt index 4ae8aae..0909832 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,11 +13,11 @@ find_package(nlohmann_json CONFIG REQUIRED) add_executable(agentforge src/main.cpp src/agent.cpp + src/agent_loop.cpp src/ollama_client.cpp src/file_tool.cpp src/tool_request_parser.cpp ) - set_target_properties(agentforge PROPERTIES CXX_STANDARD 20 CXX_STANDARD_REQUIRED ON diff --git a/src/agent_loop.cpp b/src/agent_loop.cpp new file mode 100644 index 0000000..0f08ab2 --- /dev/null +++ b/src/agent_loop.cpp @@ -0,0 +1,113 @@ +#include "agent_loop.hpp" + +#include "file_tool.hpp" +#include "ollama_client.hpp" + +#include + +#include +#include +#include +#include +#include + +AgentLoop::AgentLoop(const OllamaClient& client, const FileTool& file_tool) + : client_(client), file_tool_(file_tool) {} + +std::string AgentLoop::run( + const std::vector& history, + const std::string& task) const { + using nlohmann::json; + + json messages = json::array(); + + for (const auto& message : history) { + messages.push_back({ + {"role", message.role}, + {"content", message.content} + }); + } + + messages.push_back({{"role", "user"}, {"content", task}}); + + constexpr std::size_t max_model_steps = 3; + constexpr std::size_t max_file_bytes = 4000; + + for (std::size_t step = 0; step < max_model_steps; ++step) { + const ModelResponse response = client_.chat_with_tools(messages); + + if (response.tool_calls.empty()) { + return response.content; + } + + if (step + 1 == max_model_steps) { + throw std::runtime_error( + "Agent step limit reached before a final answer."); + } + + if (response.tool_calls.size() != 1) { + throw std::runtime_error( + "Expected exactly one tool call."); + } + + const json& call = response.tool_calls.at(0); + + if (!call.is_object() || + !call.contains("function") || + !call.at("function").is_object()) { + throw std::runtime_error("Invalid tool call."); + } + + const json& function = call.at("function"); + + if (!function.contains("name") || + function.at("name") != "read_file" || + !function.contains("arguments") || + !function.at("arguments").is_object()) { + throw std::runtime_error("Unsupported tool call."); + } + + const json& arguments = function.at("arguments"); + + if (arguments.size() != 1 || + !arguments.contains("path") || + !arguments.at("path").is_string()) { + throw std::runtime_error("Invalid read_file arguments."); + } + + const std::string path = + arguments.at("path").get(); + + if (path.empty()) { + throw std::runtime_error("read_file path is empty."); + } + + messages.push_back({ + {"role", "assistant"}, + {"content", response.content}, + {"tool_calls", response.tool_calls} + }); + + std::cout << "Using read_file tool...\n" << std::flush; + + std::string tool_result; + + try { + tool_result = file_tool_.read(path); + + if (tool_result.size() > max_file_bytes) { + tool_result = "File is too large to send to the model."; + } + } catch (const std::exception& error) { + tool_result = std::string("File error: ") + error.what(); + } + + messages.push_back({ + {"role", "tool"}, + {"tool_name", "read_file"}, + {"content", tool_result} + }); + } + + throw std::runtime_error("Agent step limit reached."); +} \ No newline at end of file diff --git a/src/agent_loop.hpp b/src/agent_loop.hpp new file mode 100644 index 0000000..9648c54 --- /dev/null +++ b/src/agent_loop.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include "chat_message.hpp" + +#include +#include + +class FileTool; +class OllamaClient; + +class AgentLoop { +public: + AgentLoop(const OllamaClient& client, const FileTool& file_tool); + + std::string run( + const std::vector& history, + const std::string& task) const; + +private: + const OllamaClient& client_; + const FileTool& file_tool_; +}; \ No newline at end of file diff --git a/src/model_response.hpp b/src/model_response.hpp new file mode 100644 index 0000000..3f06602 --- /dev/null +++ b/src/model_response.hpp @@ -0,0 +1,10 @@ +#pragma once + +#include + +#include + +struct ModelResponse { + std::string content; + nlohmann::json tool_calls = nlohmann::json::array(); +}; \ No newline at end of file diff --git a/src/ollama_client.cpp b/src/ollama_client.cpp index 72beab3..8eded2a 100644 --- a/src/ollama_client.cpp +++ b/src/ollama_client.cpp @@ -6,10 +6,12 @@ #include #include #include +#include namespace { -// libcurl calls this function when response data arrives. +using nlohmann::json; + std::size_t collect_response(char* data, std::size_t size, std::size_t count, void* userdata) noexcept { const std::size_t bytes = size * count; @@ -22,7 +24,6 @@ std::size_t collect_response(char* data, std::size_t size, } } -// Convert libcurl errors into C++ exceptions. void check_curl(CURLcode code) { if (code != CURLE_OK) { throw std::runtime_error( @@ -30,24 +31,10 @@ void check_curl(CURLcode code) { } } -} - -std::string OllamaClient::chat( - const std::vector& messages) const { - using nlohmann::json; - - json json_messages = json::array(); - - for (const auto& message : messages) { - json_messages.push_back({ - {"role", message.role}, - {"content", message.content} - }); - } - - const json request = { +json make_request(const json& messages) { + return { {"model", "qwen3:1.7b"}, - {"messages", json_messages}, + {"messages", messages}, {"stream", false}, {"think", false}, {"options", { @@ -55,7 +42,9 @@ std::string OllamaClient::chat( {"num_predict", 128} }} }; +} +json send_request(const json& request) { const std::string body = request.dump(); std::string response; @@ -93,7 +82,8 @@ std::string OllamaClient::chat( if (status != 200) { throw std::runtime_error( - "Ollama returned HTTP " + std::to_string(status) + ": " + response); + "Ollama returned HTTP " + std::to_string(status) + ": " + + response); } const json result = json::parse(response); @@ -102,7 +92,26 @@ std::string OllamaClient::chat( throw std::runtime_error("Ollama returned an incomplete response."); } - std::string answer = result.at("message").at("content").get(); + return result; +} + +} // namespace + +std::string OllamaClient::chat( + const std::vector& messages) const { + json json_messages = json::array(); + + for (const auto& message : messages) { + json_messages.push_back({ + {"role", message.role}, + {"content", message.content} + }); + } + + const json result = send_request(make_request(json_messages)); + + std::string answer = + result.at("message").at("content").get(); if (answer.empty()) { throw std::runtime_error("Ollama returned an empty answer."); @@ -113,4 +122,63 @@ std::string OllamaClient::chat( } return answer; +} + +ModelResponse OllamaClient::chat_with_tools( + const json& messages) const { + if (!messages.is_array()) { + throw std::invalid_argument("Messages must be a JSON array."); + } + + const json parameters = { + {"type", "object"}, + {"required", json::array({"path"})}, + {"properties", {{"path", { + {"type", "string"}, + {"description", "Workspace-relative file path"} + }}}}, + {"additionalProperties", false} + }; + + const json file_tool = { + {"type", "function"}, + {"function", { + {"name", "read_file"}, + {"description", "Read a text file in the project workspace"}, + {"parameters", parameters} + }} + }; + + json request = make_request(messages); + request["tools"] = json::array({file_tool}); + + const json result = send_request(request); + const json& message = result.at("message"); + + ModelResponse reply; + reply.content = message.at("content").get(); + + if (message.contains("tool_calls") && + !message.at("tool_calls").is_null()) { + if (!message.at("tool_calls").is_array()) { + throw std::runtime_error("Ollama returned invalid tool calls."); + } + + reply.tool_calls = message.at("tool_calls"); + } + + if (reply.content.empty() && reply.tool_calls.empty()) { + throw std::runtime_error("Ollama returned an empty response."); + } + + if (result.value("done_reason", "") == "length") { + if (!reply.tool_calls.empty()) { + throw std::runtime_error( + "Ollama stopped while generating a tool call."); + } + + reply.content += "\n[Response stopped at the output limit.]"; + } + + return reply; } \ No newline at end of file diff --git a/src/ollama_client.hpp b/src/ollama_client.hpp index ab71fe5..6708e13 100644 --- a/src/ollama_client.hpp +++ b/src/ollama_client.hpp @@ -1,6 +1,9 @@ #pragma once #include "chat_message.hpp" +#include "model_response.hpp" + +#include #include #include @@ -8,4 +11,7 @@ class OllamaClient { public: std::string chat(const std::vector& messages) const; + + ModelResponse chat_with_tools( + const nlohmann::json& messages) const; }; \ No newline at end of file