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
+11
View File
@@ -0,0 +1,11 @@
# Required: path to a local GGUF model for llama.cpp.
LLAMA_MODEL_PATH=/absolute/path/to/model.gguf
# Optional llama.cpp server settings.
LLAMA_HOST=127.0.0.1
LLAMA_PORT=8080
LLAMA_CONTEXT_SIZE=
LLAMA_EXTRA_ARGS=
# Optional controller logging.
CONTROLLER_LOG_LEVEL=info
+32
View File
@@ -0,0 +1,32 @@
name: CI
on:
push:
branches:
- main
pull_request:
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust if needed
shell: bash
run: |
if ! command -v cargo >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
fi
- name: Show Rust versions
shell: bash
run: |
rustc --version
cargo --version
- name: Run precommit checks
shell: bash
run: scripts/precommit-check.sh
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
exec scripts/precommit-check.sh
+9
View File
@@ -0,0 +1,9 @@
/target/
/.env
/.env.*
!/.env.example
/logs/
/runtime/
/dictionary/actions.jsonl
/dictionary/actions.index.json
*.log
+11
View File
@@ -0,0 +1,11 @@
when:
- event: push
- event: pull_request
steps:
validate:
image: rust:1.95
commands:
- rustc --version
- cargo --version
- scripts/precommit-check.sh
+69
View File
@@ -0,0 +1,69 @@
# AGENTS.md
## Project
`mp-ai-module-controller` is a Rust CLI scaffold for local llama.cpp-driven action dispatch. It starts a local `llama-server`, generates an action dictionary for small-model training and runtime lookup, queries the server through its OpenAI-compatible chat endpoint, and dispatches exact-match Rust scripts.
## Commands
- Build: `cargo build`
- Test: `cargo test`
- Generate dictionary: `cargo run -- dictionary generate`
- Start llama.cpp: `cargo run -- serve`
- Query local model: `cargo run -- query "log hello from codex"`
- Direct proof-of-concept dispatch: `cargo run -- run logger --payload '{"message":"hello from codex"}'`
- Codex setup: `scripts/codex-setup.sh`
- Codex logger smoke test: `scripts/codex-run-logger.sh`
- Precommit/CI check: `scripts/precommit-check.sh`
## Environment
Configuration is read from environment variables:
- `LLAMA_MODEL_PATH`: required GGUF model path for `cargo run -- serve`.
- `LLAMA_HOST`: llama.cpp host. Default: `127.0.0.1`.
- `LLAMA_PORT`: llama.cpp port. Default: `8080`.
- `LLAMA_CONTEXT_SIZE`: optional value passed as `--ctx-size`.
- `LLAMA_EXTRA_ARGS`: optional shell-split arguments appended to `llama-server`.
- `CONTROLLER_LOG_LEVEL`: tracing log level. Default: `info`.
Use `.env` for local values and keep it out of git. Update `.env.example`, `README.md`, `manifest.llm.json`, and `llm.txt` when environment variables or commands change.
## Dictionary Rules
- `src/registry.rs` is the source of truth for action definitions.
- `dictionary/actions.jsonl` is generated canonical training data.
- `dictionary/actions.index.json` is the generated runtime lookup file.
- Regenerate dictionary files with `cargo run -- dictionary generate` after changing registered actions.
- Do not manually edit generated dictionary files.
## Dispatch Rules
- Dispatch only exact `action_id` values present in `dictionary/actions.index.json`.
- Aliases and intent examples are training hints only; they are not executable ids.
- Unknown, malformed, ambiguous, missing, or empty action ids must be rejected without running scripts.
- Script processes are detached. The controller spawns them and does not monitor completion.
- Keep script payload validation strict and local to the script module.
## Generated Paths
- `target/`
- `logs/`
- `runtime/`
- `dictionary/actions.jsonl`
- `dictionary/actions.index.json`
## CI And Hooks
- Gitea Actions workflow lives at `.gitea/workflows/ci.yml`.
- Woodpecker workflow lives at `.woodpecker.yml`.
- The committed pre-commit hook lives at `.githooks/pre-commit`.
- Configure local hooks with `git config core.hooksPath .githooks`.
- All CI and pre-commit checks should call `scripts/precommit-check.sh` so dictionary generation stays validated consistently.
## Coding Guidelines
- Keep the CLI Rust-first and small.
- Prefer adding scripts under `src/scripts/` and registering them in `src/registry.rs`.
- Add focused tests for parsing, dictionary generation, action lookup, and payload validation.
- Run `cargo test` before handing off code changes.
Generated
+1714
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "mp-ai-module-controller"
version = "0.1.0"
edition = "2024"
description = "Rust controller for llama.cpp-driven local module dispatch."
[dependencies]
anyhow = "1.0"
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
clap = { version = "4.5", features = ["derive"] }
dotenvy = "0.15"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
shell-words = "1.1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[dev-dependencies]
tempfile = "3.10"
+75
View File
@@ -0,0 +1,75 @@
# mp-ai-module-controller
Rust controller for starting a local `llama.cpp` server, asking it to map natural-language requests to known local actions, and dispatching exact-match Rust scripts.
## Setup
```sh
cp .env.example .env
# edit LLAMA_MODEL_PATH in .env
cargo build
cargo run -- dictionary generate
git config core.hooksPath .githooks
```
The controller expects `llama-server` from `llama.cpp` to be installed. On this machine the planned binary is `/opt/homebrew/bin/llama-server`; if that path is not present, the controller falls back to `llama-server` on `PATH`.
## Commands
```sh
cargo run -- dictionary generate
cargo run -- serve
cargo run -- query "log hello from codex"
cargo run -- run logger --payload '{"message":"hello from direct dispatch"}'
scripts/precommit-check.sh
```
`serve` starts:
```sh
llama-server --model "$LLAMA_MODEL_PATH" --host "$LLAMA_HOST" --port "$LLAMA_PORT"
```
Optional environment variables:
- `LLAMA_CONTEXT_SIZE`: appended as `--ctx-size`.
- `LLAMA_EXTRA_ARGS`: shell-split and appended to `llama-server`.
- `CONTROLLER_LOG_LEVEL`: tracing log level, default `info`.
## Dictionary
Run `cargo run -- dictionary generate` to create:
- `dictionary/actions.jsonl`: canonical training dictionary, one action record per line.
- `dictionary/actions.index.json`: compact runtime lookup index.
The runtime only dispatches exact `action_id` matches from `actions.index.json`. Aliases and examples are training hints, not executable ids.
## LLM Response Contract
The local model must return compact JSON:
```json
{"action_id":"logger","payload":{"message":"hello"}}
```
Malformed JSON, missing `action_id`, empty `action_id`, aliases, and unknown action ids are rejected without running anything.
## Initial Script
The first registered script is `logger`. It writes JSONL records to `logs/controller-actions.jsonl`. Dispatch is detached: after the controller spawns the script process, it does not monitor completion.
## CI And Hooks
The project includes:
- `.gitea/workflows/ci.yml` for Gitea Actions.
- `.woodpecker.yml` for Woodpecker.
- `.githooks/pre-commit` for local pre-commit checks.
- `scripts/precommit-check.sh` as the shared validation entrypoint.
Enable the hook after cloning:
```sh
git config core.hooksPath .githooks
```
+41
View File
@@ -0,0 +1,41 @@
# mp-ai-module-controller
Rust CLI for local llama.cpp action dispatch.
Important files:
- `src/main.rs`: CLI entrypoint.
- `src/registry.rs`: source-of-truth action registry.
- `src/dictionary.rs`: generates `dictionary/actions.jsonl` and `dictionary/actions.index.json`.
- `src/llm.rs`: sends OpenAI-compatible chat requests to the local llama.cpp server and parses action JSON.
- `src/dispatcher.rs`: exact-match runtime dispatch and detached script spawning.
- `src/scripts/logger.rs`: proof-of-concept logger script.
- `AGENTS.md`: agent instructions.
- `manifest.llm.json`: machine-readable project metadata.
- `.env.example`: environment variable template.
Commands:
- `cargo build`
- `cargo test`
- `cargo run -- dictionary generate`
- `cargo run -- serve`
- `cargo run -- query "log hello from codex"`
- `cargo run -- run logger --payload '{"message":"hello from codex"}'`
- `scripts/codex-setup.sh`
- `scripts/codex-run-logger.sh`
- `scripts/precommit-check.sh`
- `git config core.hooksPath .githooks`
Environment:
- `LLAMA_MODEL_PATH`: required for `serve`; local GGUF model path.
- `LLAMA_HOST`: default `127.0.0.1`.
- `LLAMA_PORT`: default `8080`.
- `LLAMA_CONTEXT_SIZE`: optional `--ctx-size`.
- `LLAMA_EXTRA_ARGS`: optional extra llama-server args.
- `CONTROLLER_LOG_LEVEL`: default `info`.
The LLM must return compact JSON like `{"action_id":"logger","payload":{"message":"hello"}}`. The runtime only executes exact `action_id` matches from `dictionary/actions.index.json`; aliases are not executable. Invalid or unknown outputs are rejected without running anything.
CI and hooks: `.gitea/workflows/ci.yml`, `.woodpecker.yml`, and `.githooks/pre-commit` all use `scripts/precommit-check.sh` to verify formatting, tests, and dictionary generation.
+124
View File
@@ -0,0 +1,124 @@
{
"schema_version": "1.0",
"name": "mp-ai-module-controller",
"description": "Rust CLI controller for llama.cpp-backed local action dispatch.",
"project_type": "cli",
"language": "rust",
"package_manager": "cargo",
"entrypoints": {
"main": "src/main.rs",
"action_registry": "src/registry.rs",
"dictionary_generator": "src/dictionary.rs",
"llm_client": "src/llm.rs",
"dispatcher": "src/dispatcher.rs",
"logger_script": "src/scripts/logger.rs"
},
"commands": {
"build": "cargo build",
"test": "cargo test",
"generate_dictionary": "cargo run -- dictionary generate",
"serve": "cargo run -- serve",
"query": "cargo run -- query \"log hello from codex\"",
"run_logger": "cargo run -- run logger --payload '{\"message\":\"hello from codex\"}'",
"codex_setup": "scripts/codex-setup.sh",
"codex_logger_smoke": "scripts/codex-run-logger.sh",
"precommit_check": "scripts/precommit-check.sh",
"install_hooks": "git config core.hooksPath .githooks"
},
"environment": {
"example_file": ".env.example",
"variables": [
{
"name": "LLAMA_MODEL_PATH",
"required": true,
"secret": false,
"description": "Absolute path to a local GGUF model used by llama-server."
},
{
"name": "LLAMA_HOST",
"required": false,
"secret": false,
"default": "127.0.0.1",
"description": "Host for the local llama.cpp server."
},
{
"name": "LLAMA_PORT",
"required": false,
"secret": false,
"default": "8080",
"description": "Port for the local llama.cpp server."
},
{
"name": "LLAMA_CONTEXT_SIZE",
"required": false,
"secret": false,
"description": "Optional context size passed to llama-server as --ctx-size."
},
{
"name": "LLAMA_EXTRA_ARGS",
"required": false,
"secret": false,
"description": "Optional shell-split extra arguments appended to llama-server."
},
{
"name": "CONTROLLER_LOG_LEVEL",
"required": false,
"secret": false,
"default": "info",
"description": "Tracing log level for the controller."
}
]
},
"llama_cpp": {
"server_binary": "/opt/homebrew/bin/llama-server",
"fallback_binary": "llama-server",
"api": "OpenAI-compatible /v1/chat/completions",
"response_contract": {
"format": "compact JSON",
"example": {
"action_id": "logger",
"payload": {
"message": "hello"
}
}
}
},
"dictionary": {
"source_of_truth": "src/registry.rs",
"generated_jsonl": "dictionary/actions.jsonl",
"generated_index": "dictionary/actions.index.json",
"runtime_policy": "Only exact action_id matches from the generated index are executable."
},
"actions": [
{
"action_id": "logger",
"script": "src/scripts/logger.rs",
"description": "Proof-of-concept script that writes structured JSONL log records.",
"payload_example": {
"message": "hello from codex"
},
"output": "logs/controller-actions.jsonl"
}
],
"generated_paths": [
"target/",
"logs/",
"runtime/",
"dictionary/actions.jsonl",
"dictionary/actions.index.json"
],
"ci": {
"gitea_actions": ".gitea/workflows/ci.yml",
"woodpecker": ".woodpecker.yml",
"precommit_hook": ".githooks/pre-commit",
"shared_check": "scripts/precommit-check.sh",
"checks": [
"cargo fmt --check",
"cargo test",
"cargo run -- dictionary generate",
"test -s dictionary/actions.jsonl",
"test -s dictionary/actions.index.json"
]
},
"agent_guidance": "See AGENTS.md. Keep src/registry.rs as the source of truth, regenerate dictionary files after action changes, and reject non-exact LLM action outputs."
}
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
cargo run -- dictionary generate
cargo run -- run logger --payload '{"message":"hello from codex"}'
echo "logger dispatched; see logs/controller-actions.jsonl"
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
command -v rustc >/dev/null
command -v cargo >/dev/null
if [[ -x /opt/homebrew/bin/llama-server ]]; then
echo "llama-server: /opt/homebrew/bin/llama-server"
else
command -v llama-server >/dev/null
echo "llama-server: $(command -v llama-server)"
fi
mkdir -p dictionary logs runtime
cargo build
cargo run -- dictionary generate
echo "codex setup complete"
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
cargo fmt --check
cargo test
cargo run -- dictionary generate
test -s dictionary/actions.jsonl
test -s dictionary/actions.index.json
echo "precommit check complete"
+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()
}
}