This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
use std::env;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ControllerConfig {
|
||||
pub llama_host: String,
|
||||
pub llama_port: u16,
|
||||
}
|
||||
|
||||
impl ControllerConfig {
|
||||
pub fn from_env() -> Self {
|
||||
let llama_host = env::var("LLAMA_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
let llama_port = env::var("LLAMA_PORT")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u16>().ok())
|
||||
.unwrap_or(8080);
|
||||
|
||||
Self {
|
||||
llama_host,
|
||||
llama_port,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn base_url(&self) -> String {
|
||||
format!("http://{}:{}", self.llama_host, self.llama_port)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::{self, File};
|
||||
use std::io::Write;
|
||||
#[cfg(test)]
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::registry::ActionDefinition;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ActionDictionaryRecord {
|
||||
pub action_id: String,
|
||||
pub aliases: Vec<String>,
|
||||
pub description: String,
|
||||
pub intent_examples: Vec<String>,
|
||||
pub payload_schema: serde_json::Value,
|
||||
pub executable: crate::registry::ExecutableDefinition,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ActionIndex {
|
||||
pub schema_version: String,
|
||||
pub actions: BTreeMap<String, ActionDictionaryRecord>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DictionarySummary {
|
||||
pub action_count: usize,
|
||||
pub jsonl_path: PathBuf,
|
||||
pub index_path: PathBuf,
|
||||
}
|
||||
|
||||
pub fn default_dictionary_dir() -> PathBuf {
|
||||
PathBuf::from("dictionary")
|
||||
}
|
||||
|
||||
pub fn generate_dictionary_files(
|
||||
actions: &[ActionDefinition],
|
||||
out_dir: &Path,
|
||||
) -> Result<DictionarySummary> {
|
||||
fs::create_dir_all(out_dir)
|
||||
.with_context(|| format!("creating dictionary directory {}", out_dir.display()))?;
|
||||
|
||||
let jsonl_path = out_dir.join("actions.jsonl");
|
||||
let index_path = out_dir.join("actions.index.json");
|
||||
let records = actions
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(ActionDictionaryRecord::from)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut jsonl =
|
||||
File::create(&jsonl_path).with_context(|| format!("creating {}", jsonl_path.display()))?;
|
||||
for record in &records {
|
||||
serde_json::to_writer(&mut jsonl, record)?;
|
||||
jsonl.write_all(b"\n")?;
|
||||
}
|
||||
|
||||
let index = ActionIndex {
|
||||
schema_version: "1.0".to_string(),
|
||||
actions: records
|
||||
.into_iter()
|
||||
.map(|record| (record.action_id.clone(), record))
|
||||
.collect(),
|
||||
};
|
||||
|
||||
let pretty = serde_json::to_string_pretty(&index)?;
|
||||
fs::write(&index_path, format!("{pretty}\n"))
|
||||
.with_context(|| format!("writing {}", index_path.display()))?;
|
||||
|
||||
Ok(DictionarySummary {
|
||||
action_count: index.actions.len(),
|
||||
jsonl_path,
|
||||
index_path,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load_action_index(dictionary_dir: &Path) -> Result<ActionIndex> {
|
||||
let path = dictionary_dir.join("actions.index.json");
|
||||
let raw = fs::read_to_string(&path).with_context(|| {
|
||||
format!(
|
||||
"reading {}; run `cargo run -- dictionary generate` first",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
let index = serde_json::from_str::<ActionIndex>(&raw)
|
||||
.with_context(|| format!("parsing {}", path.display()))?;
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn read_jsonl_records(path: &Path) -> Result<Vec<ActionDictionaryRecord>> {
|
||||
let file = File::open(path).with_context(|| format!("opening {}", path.display()))?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut records = Vec::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = line?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
records.push(serde_json::from_str::<ActionDictionaryRecord>(&line)?);
|
||||
}
|
||||
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
impl From<ActionDefinition> for ActionDictionaryRecord {
|
||||
fn from(action: ActionDefinition) -> Self {
|
||||
Self {
|
||||
action_id: action.action_id,
|
||||
aliases: action.aliases,
|
||||
description: action.description,
|
||||
intent_examples: action.intent_examples,
|
||||
payload_schema: action.payload_schema,
|
||||
executable: action.executable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::registry::registered_actions;
|
||||
|
||||
#[test]
|
||||
fn generates_jsonl_and_index() {
|
||||
let temp = tempfile::tempdir().expect("temp dir");
|
||||
let summary = generate_dictionary_files(®istered_actions(), temp.path()).unwrap();
|
||||
|
||||
assert_eq!(summary.action_count, 1);
|
||||
assert!(summary.jsonl_path.exists());
|
||||
assert!(summary.index_path.exists());
|
||||
|
||||
let records = read_jsonl_records(&summary.jsonl_path).unwrap();
|
||||
assert_eq!(records.len(), 1);
|
||||
assert_eq!(records[0].action_id, "logger");
|
||||
|
||||
let index = load_action_index(temp.path()).unwrap();
|
||||
assert!(index.actions.contains_key("logger"));
|
||||
assert!(!index.actions.contains_key("log"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use serde_json::Value;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use crate::dictionary::ActionIndex;
|
||||
use crate::scripts::logger::{LoggerPayload, run_logger};
|
||||
|
||||
pub fn dispatch_action(index: &ActionIndex, action_id: &str, payload: Value) -> Result<()> {
|
||||
let action = index
|
||||
.actions
|
||||
.get(action_id)
|
||||
.ok_or_else(|| anyhow!("unknown action_id `{action_id}`; refusing to dispatch"))?;
|
||||
|
||||
if action.executable.kind != "internal-rust-script" {
|
||||
bail!(
|
||||
"unsupported executable kind `{}` for action `{}`",
|
||||
action.executable.kind,
|
||||
action.action_id
|
||||
);
|
||||
}
|
||||
|
||||
let payload = serde_json::to_string(&payload)?;
|
||||
let current_exe = std::env::current_exe().context("resolving current executable")?;
|
||||
let log_file = open_child_log()?;
|
||||
|
||||
Command::new(current_exe)
|
||||
.arg("__script")
|
||||
.arg(&action.executable.script)
|
||||
.arg("--payload")
|
||||
.arg(payload)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::from(log_file.try_clone()?))
|
||||
.stderr(Stdio::from(log_file))
|
||||
.spawn()
|
||||
.with_context(|| format!("spawning action `{action_id}`"))?;
|
||||
|
||||
tracing::info!("dispatched action `{}`", action_id);
|
||||
println!("dispatched action `{action_id}`");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_internal_script(action_id: &str, payload: Value) -> Result<()> {
|
||||
match action_id {
|
||||
"logger" => {
|
||||
let payload = serde_json::from_value::<LoggerPayload>(payload)?;
|
||||
run_logger(payload)
|
||||
}
|
||||
other => bail!("unknown internal script `{other}`"),
|
||||
}
|
||||
}
|
||||
|
||||
fn open_child_log() -> Result<std::fs::File> {
|
||||
fs::create_dir_all("logs").context("creating logs directory")?;
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open("logs/controller-child.log")
|
||||
.context("opening logs/controller-child.log")?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dictionary::ActionIndex;
|
||||
use crate::registry::registered_actions;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn test_index() -> ActionIndex {
|
||||
let actions = registered_actions()
|
||||
.into_iter()
|
||||
.map(crate::dictionary::ActionDictionaryRecord::from)
|
||||
.map(|record| (record.action_id.clone(), record))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
|
||||
ActionIndex {
|
||||
schema_version: "1.0".to_string(),
|
||||
actions,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_action_lookup_accepts_registered_id() {
|
||||
let index = test_index();
|
||||
assert!(index.actions.contains_key("logger"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_action_lookup_rejects_alias() {
|
||||
let index = test_index();
|
||||
let result = dispatch_action(&index, "log", serde_json::json!({ "message": "test" }));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use reqwest::blocking::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config::ControllerConfig;
|
||||
use crate::dictionary::ActionIndex;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ActionRequest {
|
||||
pub action_id: String,
|
||||
#[serde(default)]
|
||||
pub payload: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ChatRequest {
|
||||
model: String,
|
||||
messages: Vec<ChatMessage>,
|
||||
temperature: f32,
|
||||
stream: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ChatMessage {
|
||||
role: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChatResponse {
|
||||
choices: Vec<ChatChoice>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChatChoice {
|
||||
message: ChatChoiceMessage,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChatChoiceMessage {
|
||||
content: String,
|
||||
}
|
||||
|
||||
pub fn query_llama(config: &ControllerConfig, index: &ActionIndex, text: &str) -> Result<String> {
|
||||
let actions = serde_json::to_string(&index.actions)?;
|
||||
let system = format!(
|
||||
"You map user requests to exact local action ids. Return only compact JSON with shape {{\"action_id\":\"...\",\"payload\":{{...}}}}. Valid actions index: {actions}"
|
||||
);
|
||||
let request = ChatRequest {
|
||||
model: "local".to_string(),
|
||||
messages: vec![
|
||||
ChatMessage {
|
||||
role: "system".to_string(),
|
||||
content: system,
|
||||
},
|
||||
ChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: text.to_string(),
|
||||
},
|
||||
],
|
||||
temperature: 0.0,
|
||||
stream: false,
|
||||
};
|
||||
|
||||
let url = format!("{}/v1/chat/completions", config.base_url());
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(120))
|
||||
.build()
|
||||
.context("building HTTP client")?;
|
||||
let response = client
|
||||
.post(&url)
|
||||
.json(&request)
|
||||
.send()
|
||||
.with_context(|| format!("posting to {url}"))?
|
||||
.error_for_status()
|
||||
.with_context(|| format!("llama.cpp server returned an error for {url}"))?
|
||||
.json::<ChatResponse>()
|
||||
.context("parsing OpenAI-compatible chat completion response")?;
|
||||
|
||||
response
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|choice| choice.message.content)
|
||||
.ok_or_else(|| anyhow!("llama.cpp response had no choices"))
|
||||
}
|
||||
|
||||
pub fn parse_action_response(raw: &str) -> Result<ActionRequest> {
|
||||
let trimmed = raw.trim();
|
||||
let json_text = if trimmed.starts_with('{') {
|
||||
trimmed
|
||||
} else {
|
||||
extract_first_json_object(trimmed)
|
||||
.ok_or_else(|| anyhow!("LLM response did not contain a JSON object"))?
|
||||
};
|
||||
|
||||
let request = serde_json::from_str::<ActionRequest>(json_text)
|
||||
.with_context(|| format!("parsing LLM action JSON: {json_text}"))?;
|
||||
|
||||
if request.action_id.trim().is_empty() {
|
||||
bail!("LLM action JSON had an empty action_id");
|
||||
}
|
||||
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
fn extract_first_json_object(text: &str) -> Option<&str> {
|
||||
let start = text.find('{')?;
|
||||
let mut depth = 0_i32;
|
||||
let mut in_string = false;
|
||||
let mut escaped = false;
|
||||
|
||||
for (offset, ch) in text[start..].char_indices() {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
match ch {
|
||||
'\\' if in_string => escaped = true,
|
||||
'"' => in_string = !in_string,
|
||||
'{' if !in_string => depth += 1,
|
||||
'}' if !in_string => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
let end = start + offset + ch.len_utf8();
|
||||
return Some(&text[start..end]);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_valid_action_response() {
|
||||
let parsed =
|
||||
parse_action_response(r#"{"action_id":"logger","payload":{"message":"hello"}}"#)
|
||||
.unwrap();
|
||||
assert_eq!(parsed.action_id, "logger");
|
||||
assert_eq!(parsed.payload["message"], "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_embedded_json_response() {
|
||||
let parsed = parse_action_response(
|
||||
r#"Here is the action: {"action_id":"logger","payload":{"message":"hello"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(parsed.action_id, "logger");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_response() {
|
||||
assert!(parse_action_response("run logger please").is_err());
|
||||
assert!(parse_action_response(r#"{"payload":{"message":"missing id"}}"#).is_err());
|
||||
assert!(parse_action_response(r#"{"action_id":"","payload":{}}"#).is_err());
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
mod config;
|
||||
mod dictionary;
|
||||
mod dispatcher;
|
||||
mod llm;
|
||||
mod registry;
|
||||
mod scripts;
|
||||
mod server;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::config::ControllerConfig;
|
||||
use crate::dictionary::{default_dictionary_dir, generate_dictionary_files, load_action_index};
|
||||
use crate::dispatcher::{dispatch_action, run_internal_script};
|
||||
use crate::llm::{parse_action_response, query_llama};
|
||||
use crate::registry::registered_actions;
|
||||
use crate::server::start_llama_server;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "mp-ai-module-controller")]
|
||||
#[command(about = "Control local Rust scripts through a llama.cpp action dictionary.")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum Command {
|
||||
/// Start a llama.cpp server using LLAMA_MODEL_PATH.
|
||||
Serve,
|
||||
/// Generate or inspect the LLM action dictionary.
|
||||
Dictionary {
|
||||
#[command(subcommand)]
|
||||
command: DictionaryCommand,
|
||||
},
|
||||
/// Query the local LLM and dispatch the exact action id it returns.
|
||||
Query {
|
||||
/// Natural-language request to send to the local model.
|
||||
text: String,
|
||||
},
|
||||
/// Dispatch a known action id directly.
|
||||
Run {
|
||||
/// Exact action id from dictionary/actions.index.json.
|
||||
action_id: String,
|
||||
/// JSON payload for the action. Defaults to the logger proof-of-concept payload.
|
||||
#[arg(long)]
|
||||
payload: Option<String>,
|
||||
},
|
||||
/// Internal detached script runner. Not part of the public CLI.
|
||||
#[command(name = "__script", hide = true)]
|
||||
Script {
|
||||
action_id: String,
|
||||
#[arg(long)]
|
||||
payload: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum DictionaryCommand {
|
||||
/// Generate dictionary/actions.jsonl and dictionary/actions.index.json.
|
||||
Generate {
|
||||
/// Output directory for generated dictionary files.
|
||||
#[arg(long)]
|
||||
out_dir: Option<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let _ = dotenvy::dotenv();
|
||||
init_tracing();
|
||||
|
||||
let cli = Cli::parse();
|
||||
match cli.command {
|
||||
Command::Serve => {
|
||||
let config = ControllerConfig::from_env();
|
||||
start_llama_server(&config)
|
||||
}
|
||||
Command::Dictionary {
|
||||
command: DictionaryCommand::Generate { out_dir },
|
||||
} => {
|
||||
let out_dir = out_dir.unwrap_or_else(default_dictionary_dir);
|
||||
let summary = generate_dictionary_files(®istered_actions(), &out_dir)?;
|
||||
println!(
|
||||
"generated {} actions: {} and {}",
|
||||
summary.action_count,
|
||||
summary.jsonl_path.display(),
|
||||
summary.index_path.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Command::Query { text } => {
|
||||
let config = ControllerConfig::from_env();
|
||||
let index = load_action_index(&default_dictionary_dir())?;
|
||||
let response = query_llama(&config, &index, &text)?;
|
||||
let request = parse_action_response(&response)?;
|
||||
dispatch_action(&index, &request.action_id, request.payload)
|
||||
}
|
||||
Command::Run { action_id, payload } => {
|
||||
let index = load_action_index(&default_dictionary_dir())?;
|
||||
let payload = match payload {
|
||||
Some(raw) => serde_json::from_str::<Value>(&raw)?,
|
||||
None => serde_json::json!({ "message": "logger proof of concept" }),
|
||||
};
|
||||
dispatch_action(&index, &action_id, payload)
|
||||
}
|
||||
Command::Script { action_id, payload } => {
|
||||
let payload = serde_json::from_str::<Value>(&payload)?;
|
||||
run_internal_script(&action_id, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
let level = std::env::var("CONTROLLER_LOG_LEVEL").unwrap_or_else(|_| "info".to_string());
|
||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.or_else(|_| tracing_subscriber::EnvFilter::try_new(level))
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
|
||||
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_target(false)
|
||||
.try_init();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ActionDefinition {
|
||||
pub action_id: String,
|
||||
pub aliases: Vec<String>,
|
||||
pub description: String,
|
||||
pub intent_examples: Vec<String>,
|
||||
pub payload_schema: Value,
|
||||
pub executable: ExecutableDefinition,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ExecutableDefinition {
|
||||
pub kind: String,
|
||||
pub script: String,
|
||||
}
|
||||
|
||||
pub fn registered_actions() -> Vec<ActionDefinition> {
|
||||
vec![ActionDefinition {
|
||||
action_id: "logger".to_string(),
|
||||
aliases: vec![
|
||||
"log".to_string(),
|
||||
"write_log".to_string(),
|
||||
"record_message".to_string(),
|
||||
],
|
||||
description: "Write a structured proof-of-concept log message.".to_string(),
|
||||
intent_examples: vec![
|
||||
"log hello from codex".to_string(),
|
||||
"record this message".to_string(),
|
||||
"write a logger proof of concept entry".to_string(),
|
||||
],
|
||||
payload_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"required": ["message"],
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "Message text to write into logs/controller-actions.jsonl."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
executable: ExecutableDefinition {
|
||||
kind: "internal-rust-script".to_string(),
|
||||
script: "logger".to_string(),
|
||||
},
|
||||
}]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct LoggerPayload {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct LoggerRecord {
|
||||
pub timestamp: String,
|
||||
pub action_id: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub fn run_logger(payload: LoggerPayload) -> Result<()> {
|
||||
let record = build_logger_record(payload)?;
|
||||
fs::create_dir_all("logs").context("creating logs directory")?;
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open("logs/controller-actions.jsonl")
|
||||
.context("opening logs/controller-actions.jsonl")?;
|
||||
|
||||
serde_json::to_writer(&mut file, &record)?;
|
||||
file.write_all(b"\n")?;
|
||||
println!("logger wrote: {}", record.message);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn build_logger_record(payload: LoggerPayload) -> Result<LoggerRecord> {
|
||||
let message = payload.message.trim();
|
||||
if message.is_empty() {
|
||||
anyhow::bail!("logger payload message must not be empty");
|
||||
}
|
||||
|
||||
Ok(LoggerRecord {
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
action_id: "logger".to_string(),
|
||||
message: message.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validates_and_serializes_payload() {
|
||||
let payload = LoggerPayload {
|
||||
message: " hello ".to_string(),
|
||||
};
|
||||
let record = build_logger_record(payload).unwrap();
|
||||
assert_eq!(record.action_id, "logger");
|
||||
assert_eq!(record.message, "hello");
|
||||
|
||||
let json = serde_json::to_string(&record).unwrap();
|
||||
assert!(json.contains(r#""action_id":"logger""#));
|
||||
assert!(json.contains(r#""message":"hello""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_message() {
|
||||
let result = build_logger_record(LoggerPayload {
|
||||
message: " ".to_string(),
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod logger;
|
||||
@@ -0,0 +1,54 @@
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::config::ControllerConfig;
|
||||
|
||||
pub fn start_llama_server(config: &ControllerConfig) -> Result<()> {
|
||||
let model_path = std::env::var("LLAMA_MODEL_PATH")
|
||||
.map_err(|_| anyhow!("LLAMA_MODEL_PATH is required and must point to a GGUF model"))?;
|
||||
|
||||
let binary = llama_server_binary();
|
||||
let mut command = Command::new(&binary);
|
||||
command
|
||||
.arg("--model")
|
||||
.arg(model_path)
|
||||
.arg("--host")
|
||||
.arg(&config.llama_host)
|
||||
.arg("--port")
|
||||
.arg(config.llama_port.to_string());
|
||||
|
||||
if let Ok(context_size) = std::env::var("LLAMA_CONTEXT_SIZE") {
|
||||
if !context_size.trim().is_empty() {
|
||||
command.arg("--ctx-size").arg(context_size);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(extra_args) = std::env::var("LLAMA_EXTRA_ARGS") {
|
||||
if !extra_args.trim().is_empty() {
|
||||
for arg in shell_words::split(&extra_args).context("parsing LLAMA_EXTRA_ARGS")? {
|
||||
command.arg(arg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("starting llama.cpp server on {}", config.base_url());
|
||||
let status = command
|
||||
.status()
|
||||
.with_context(|| format!("starting {}", binary.display()))?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("llama-server exited with status {status}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn llama_server_binary() -> std::path::PathBuf {
|
||||
let homebrew_path = Path::new("/opt/homebrew/bin/llama-server");
|
||||
if homebrew_path.exists() {
|
||||
homebrew_path.to_path_buf()
|
||||
} else {
|
||||
"llama-server".into()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user