feat: Implement Ollama AI service integration with health checks and command generation

This commit is contained in:
scawful
2025-10-03 01:00:28 -04:00
parent 40a4e43db9
commit 6cec21f7aa
6 changed files with 843 additions and 4 deletions

View File

@@ -19,6 +19,8 @@
#include "cli/handlers/agent/common.h"
#include "cli/modern_cli.h"
#include "cli/service/ai_service.h"
#include "cli/service/ollama_ai_service.h"
#include "cli/service/gemini_ai_service.h"
#include "cli/service/proposal_registry.h"
#include "cli/service/resource_catalog.h"
#include "cli/service/rom_sandbox_manager.h"
@@ -34,6 +36,48 @@ namespace agent {
namespace {
// Helper: Select AI service based on environment variables
std::unique_ptr<AIService> CreateAIService() {
// Priority: Ollama (local) > Gemini (remote) > Mock (testing)
const char* provider_env = std::getenv("YAZE_AI_PROVIDER");
const char* gemini_key = std::getenv("GEMINI_API_KEY");
const char* ollama_model = std::getenv("OLLAMA_MODEL");
// Explicit provider selection
if (provider_env && std::string(provider_env) == "ollama") {
OllamaConfig config;
// Allow model override via env
if (ollama_model && std::strlen(ollama_model) > 0) {
config.model = ollama_model;
}
auto service = std::make_unique<OllamaAIService>(config);
// Health check
if (auto status = service->CheckAvailability(); !status.ok()) {
std::cerr << "⚠️ Ollama unavailable: " << status.message() << std::endl;
std::cerr << " Falling back to MockAIService" << std::endl;
return std::make_unique<MockAIService>();
}
std::cout << "🤖 Using Ollama AI with model: " << config.model << std::endl;
return service;
}
// Gemini if API key provided
if (gemini_key && std::strlen(gemini_key) > 0) {
std::cout << "🤖 Using Gemini AI (remote)" << std::endl;
return std::make_unique<GeminiAIService>(gemini_key);
}
// Default: Mock service for testing
std::cout << "🤖 Using MockAIService (no LLM configured)" << std::endl;
std::cout << " Tip: Set YAZE_AI_PROVIDER=ollama or GEMINI_API_KEY to enable LLM" << std::endl;
return std::make_unique<MockAIService>();
}
struct DescribeOptions {
std::optional<std::string> resource;
std::string format = "json";
@@ -141,8 +185,8 @@ absl::Status HandleRunCommand(const std::vector<std::string>& arg_vec,
RETURN_IF_ERROR(ProposalRegistry::Instance().AppendLog(
proposal.id, absl::StrCat("Starting agent run with prompt: ", prompt)));
MockAIService ai_service;
auto commands_or = ai_service.GetCommands(prompt);
auto ai_service = CreateAIService(); // Use service factory
auto commands_or = ai_service->GetCommands(prompt);
if (!commands_or.ok()) {
RETURN_IF_ERROR(ProposalRegistry::Instance().AppendLog(
proposal.id,
@@ -225,8 +269,9 @@ absl::Status HandlePlanCommand(const std::vector<std::string>& arg_vec) {
return absl::InvalidArgumentError("Usage: agent plan --prompt <prompt>");
}
std::string prompt = arg_vec[1];
MockAIService ai_service;
auto commands_or = ai_service.GetCommands(prompt);
auto ai_service = CreateAIService(); // Use service factory
auto commands_or = ai_service->GetCommands(prompt);
if (!commands_or.ok()) {
return commands_or.status();
}

View File

@@ -0,0 +1,292 @@
#include "cli/service/ollama_ai_service.h"
#include <cstdlib>
#include "absl/strings/str_cat.h"
#include "absl/strings/str_format.h"
// Check if we have httplib available (from vcpkg or bundled)
#if __has_include("httplib.h")
#define YAZE_HAS_HTTPLIB 1
#include "httplib.h"
#elif __has_include("incl/httplib.h")
#define YAZE_HAS_HTTPLIB 1
#include "incl/httplib.h"
#else
#define YAZE_HAS_HTTPLIB 0
#endif
// Check if we have JSON library available
#if __has_include("third_party/json/src/json.hpp")
#define YAZE_HAS_JSON 1
#include "third_party/json/src/json.hpp"
#elif __has_include("json.hpp")
#define YAZE_HAS_JSON 1
#include "json.hpp"
#else
#define YAZE_HAS_JSON 0
#endif
namespace yaze {
namespace cli {
OllamaAIService::OllamaAIService(const OllamaConfig& config) : config_(config) {
if (config_.system_prompt.empty()) {
config_.system_prompt = BuildSystemPrompt();
}
}
std::string OllamaAIService::BuildSystemPrompt() {
// TODO: Eventually load from docs/api/z3ed-resources.yaml for full command catalogue
// For now, use a comprehensive hardcoded prompt
return R"(You are an expert ROM hacking assistant for The Legend of Zelda: A Link to the Past.
Your role is to generate PRECISE z3ed CLI commands to fulfill user requests.
CRITICAL RULES:
1. Output ONLY a JSON array of command strings
2. Each command must follow exact z3ed syntax
3. Commands must be executable without modification
4. Use only commands from the available command set
5. Include all required arguments with proper flags
AVAILABLE COMMANDS:
- rom info --rom <path>
- rom validate --rom <path>
- rom diff --rom1 <path1> --rom2 <path2>
- palette export --group <group> --id <id> --to <file>
- palette import --group <group> --id <id> --from <file>
- palette set-color --file <file> --index <index> --color <hex_color>
- overworld get-tile --map <map_id> --x <x> --y <y>
- overworld set-tile --map <map_id> --x <x> --y <y> --tile <tile_id>
- dungeon export-room --room <room_id> --to <file>
- dungeon import-room --room <room_id> --from <file>
RESPONSE FORMAT:
["command1", "command2", "command3"]
EXAMPLE 1:
User: "Validate the ROM"
Response: ["rom validate --rom zelda3.sfc"]
EXAMPLE 2:
User: "Make all soldier armors red"
Response: ["palette export --group sprites --id soldier --to /tmp/soldier.pal", "palette set-color --file /tmp/soldier.pal --index 5 --color FF0000", "palette import --group sprites --id soldier --from /tmp/soldier.pal"]
EXAMPLE 3:
User: "Export the first overworld palette"
Response: ["palette export --group overworld --id 0 --to /tmp/ow_pal_0.pal"]
Begin your response now.)";
}
absl::Status OllamaAIService::CheckAvailability() {
#if !YAZE_HAS_HTTPLIB || !YAZE_HAS_JSON
return absl::UnimplementedError(
"Ollama service requires httplib and JSON support. "
"Install vcpkg dependencies or use bundled libraries.");
#else
try {
httplib::Client cli(config_.base_url);
cli.set_connection_timeout(5); // 5 second timeout
auto res = cli.Get("/api/tags");
if (!res) {
return absl::UnavailableError(absl::StrFormat(
"Cannot connect to Ollama server at %s.\n"
"Make sure Ollama is installed and running:\n"
" 1. Install: brew install ollama (macOS) or https://ollama.com/download\n"
" 2. Start: ollama serve\n"
" 3. Verify: curl http://localhost:11434/api/tags",
config_.base_url));
}
if (res->status != 200) {
return absl::InternalError(absl::StrFormat(
"Ollama server error: HTTP %d\nResponse: %s",
res->status, res->body));
}
// Check if requested model is available
nlohmann::json models_json = nlohmann::json::parse(res->body);
bool model_found = false;
if (models_json.contains("models") && models_json["models"].is_array()) {
for (const auto& model : models_json["models"]) {
if (model.contains("name")) {
std::string model_name = model["name"].get<std::string>();
if (model_name.find(config_.model) != std::string::npos) {
model_found = true;
break;
}
}
}
}
if (!model_found) {
return absl::NotFoundError(absl::StrFormat(
"Model '%s' not found on Ollama server.\n"
"Pull it with: ollama pull %s\n"
"Available models: ollama list",
config_.model, config_.model));
}
return absl::OkStatus();
} catch (const std::exception& e) {
return absl::InternalError(absl::StrCat(
"Ollama health check failed: ", e.what()));
}
#endif
}
absl::StatusOr<std::vector<std::string>> OllamaAIService::ListAvailableModels() {
#if !YAZE_HAS_HTTPLIB || !YAZE_HAS_JSON
return absl::UnimplementedError("Requires httplib and JSON support");
#else
try {
httplib::Client cli(config_.base_url);
cli.set_connection_timeout(5);
auto res = cli.Get("/api/tags");
if (!res || res->status != 200) {
return absl::UnavailableError(
"Cannot list Ollama models. Is the server running?");
}
nlohmann::json models_json = nlohmann::json::parse(res->body);
std::vector<std::string> models;
if (models_json.contains("models") && models_json["models"].is_array()) {
for (const auto& model : models_json["models"]) {
if (model.contains("name")) {
models.push_back(model["name"].get<std::string>());
}
}
}
return models;
} catch (const std::exception& e) {
return absl::InternalError(absl::StrCat(
"Failed to list models: ", e.what()));
}
#endif
}
absl::StatusOr<std::string> OllamaAIService::ParseOllamaResponse(
const std::string& json_response) {
#if !YAZE_HAS_JSON
return absl::UnimplementedError("Requires JSON support");
#else
try {
nlohmann::json response_json = nlohmann::json::parse(json_response);
if (!response_json.contains("response")) {
return absl::InvalidArgumentError(
"Ollama response missing 'response' field");
}
return response_json["response"].get<std::string>();
} catch (const nlohmann::json::exception& e) {
return absl::InternalError(absl::StrCat(
"Failed to parse Ollama response: ", e.what()));
}
#endif
}
absl::StatusOr<std::vector<std::string>> OllamaAIService::GetCommands(
const std::string& prompt) {
#if !YAZE_HAS_HTTPLIB || !YAZE_HAS_JSON
return absl::UnimplementedError(
"Ollama service requires httplib and JSON support. "
"Install vcpkg dependencies or use bundled libraries.");
#else
// Build request payload
nlohmann::json request_body = {
{"model", config_.model},
{"prompt", config_.system_prompt + "\n\nUSER REQUEST: " + prompt},
{"stream", false},
{"options", {
{"temperature", config_.temperature},
{"num_predict", config_.max_tokens}
}},
{"format", "json"} // Force JSON output
};
try {
httplib::Client cli(config_.base_url);
cli.set_read_timeout(60); // Longer timeout for inference
auto res = cli.Post("/api/generate", request_body.dump(), "application/json");
if (!res) {
return absl::UnavailableError(
"Failed to connect to Ollama. Is 'ollama serve' running?\n"
"Start with: ollama serve");
}
if (res->status != 200) {
return absl::InternalError(absl::StrFormat(
"Ollama API error: HTTP %d\nResponse: %s",
res->status, res->body));
}
// Parse response to extract generated text
auto generated_text_or = ParseOllamaResponse(res->body);
if (!generated_text_or.ok()) {
return generated_text_or.status();
}
std::string generated_text = generated_text_or.value();
// Parse the command array from generated text
nlohmann::json commands_json;
try {
commands_json = nlohmann::json::parse(generated_text);
} catch (const nlohmann::json::exception& e) {
// Sometimes the LLM includes extra text - try to extract JSON array
size_t start = generated_text.find('[');
size_t end = generated_text.rfind(']');
if (start != std::string::npos && end != std::string::npos && end > start) {
std::string json_only = generated_text.substr(start, end - start + 1);
try {
commands_json = nlohmann::json::parse(json_only);
} catch (const nlohmann::json::exception&) {
return absl::InvalidArgumentError(
"LLM did not return valid JSON. Response:\n" + generated_text);
}
} else {
return absl::InvalidArgumentError(
"LLM did not return a JSON array. Response:\n" + generated_text);
}
}
if (!commands_json.is_array()) {
return absl::InvalidArgumentError(
"LLM did not return a JSON array. Response:\n" + generated_text);
}
std::vector<std::string> commands;
for (const auto& cmd : commands_json) {
if (cmd.is_string()) {
commands.push_back(cmd.get<std::string>());
}
}
if (commands.empty()) {
return absl::InvalidArgumentError(
"LLM returned empty command list. Prompt may be unclear.\n"
"Try rephrasing your request to be more specific.");
}
return commands;
} catch (const std::exception& e) {
return absl::InternalError(absl::StrCat(
"Ollama request failed: ", e.what()));
}
#endif
}
} // namespace cli
} // namespace yaze

View File

@@ -0,0 +1,50 @@
#ifndef YAZE_SRC_CLI_OLLAMA_AI_SERVICE_H_
#define YAZE_SRC_CLI_OLLAMA_AI_SERVICE_H_
#include <string>
#include <vector>
#include "absl/status/status.h"
#include "absl/status/statusor.h"
#include "cli/service/ai_service.h"
namespace yaze {
namespace cli {
// Ollama configuration for local LLM inference
struct OllamaConfig {
std::string base_url = "http://localhost:11434"; // Default Ollama endpoint
std::string model = "qwen2.5-coder:7b"; // Recommended for code generation
float temperature = 0.1; // Low temp for deterministic commands
int max_tokens = 2048; // Sufficient for command lists
std::string system_prompt; // Injected from resource catalogue
};
class OllamaAIService : public AIService {
public:
explicit OllamaAIService(const OllamaConfig& config);
// Generate z3ed commands from natural language prompt
absl::StatusOr<std::vector<std::string>> GetCommands(
const std::string& prompt) override;
// Health check: verify Ollama server is running and model is available
absl::Status CheckAvailability();
// List available models on Ollama server
absl::StatusOr<std::vector<std::string>> ListAvailableModels();
private:
OllamaConfig config_;
// Build system prompt from resource catalogue
std::string BuildSystemPrompt();
// Parse JSON response from Ollama API
absl::StatusOr<std::string> ParseOllamaResponse(const std::string& json_response);
};
} // namespace cli
} // namespace yaze
#endif // YAZE_SRC_CLI_OLLAMA_AI_SERVICE_H_

View File

@@ -47,6 +47,7 @@ add_executable(
cli/handlers/agent/test_commands.cc
cli/handlers/agent/gui_commands.cc
cli/service/ai_service.cc
cli/service/ollama_ai_service.cc
cli/service/proposal_registry.cc
cli/service/resource_catalog.cc
cli/service/rom_sandbox_manager.cc