feat: scaffold rust llm module controller
CI / validate (push) Successful in 35s

This commit is contained in:
2026-07-21 11:10:20 -05:00
commit b445d8a117
23 changed files with 2888 additions and 0 deletions
+26
View File
@@ -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)
}
}
+145
View File
@@ -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(&registered_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"));
}
}
+95
View File
@@ -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
View File
@@ -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
View File
@@ -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(&registered_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();
}
+50
View File
@@ -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(),
},
}]
}
+73
View File
@@ -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());
}
}
+1
View File
@@ -0,0 +1 @@
pub mod logger;
+54
View File
@@ -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()
}
}