Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
113 changes: 113 additions & 0 deletions src/agent_loop.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
#include "agent_loop.hpp"

#include "file_tool.hpp"
#include "ollama_client.hpp"

#include <nlohmann/json.hpp>

#include <cstddef>
#include <exception>
#include <iostream>
#include <stdexcept>
#include <string>

AgentLoop::AgentLoop(const OllamaClient& client, const FileTool& file_tool)
: client_(client), file_tool_(file_tool) {}

std::string AgentLoop::run(
const std::vector<ChatMessage>& 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<std::string>();

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.");
}
22 changes: 22 additions & 0 deletions src/agent_loop.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#pragma once

#include "chat_message.hpp"

#include <string>
#include <vector>

class FileTool;
class OllamaClient;

class AgentLoop {
public:
AgentLoop(const OllamaClient& client, const FileTool& file_tool);

std::string run(
const std::vector<ChatMessage>& history,
const std::string& task) const;

private:
const OllamaClient& client_;
const FileTool& file_tool_;
};
10 changes: 10 additions & 0 deletions src/model_response.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#pragma once

#include <nlohmann/json.hpp>

#include <string>

struct ModelResponse {
std::string content;
nlohmann::json tool_calls = nlohmann::json::array();
};
110 changes: 89 additions & 21 deletions src/ollama_client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
#include <cstddef>
#include <memory>
#include <stdexcept>
#include <string>

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;
Expand All @@ -22,40 +24,27 @@ 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(
std::string("HTTP request failed: ") + curl_easy_strerror(code));
}
}

}

std::string OllamaClient::chat(
const std::vector<ChatMessage>& 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", {
{"num_ctx", 2048},
{"num_predict", 128}
}}
};
}

json send_request(const json& request) {
const std::string body = request.dump();
std::string response;

Expand Down Expand Up @@ -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);
Expand All @@ -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<std::string>();
return result;
}

} // namespace

std::string OllamaClient::chat(
const std::vector<ChatMessage>& 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<std::string>();

if (answer.empty()) {
throw std::runtime_error("Ollama returned an empty answer.");
Expand All @@ -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<std::string>();

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;
}
6 changes: 6 additions & 0 deletions src/ollama_client.hpp
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
#pragma once

#include "chat_message.hpp"
#include "model_response.hpp"

#include <nlohmann/json.hpp>

#include <string>
#include <vector>

class OllamaClient {
public:
std::string chat(const std::vector<ChatMessage>& messages) const;

ModelResponse chat_with_tools(
const nlohmann::json& messages) const;
};