This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
exec scripts/precommit-check.sh
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/target/
|
||||||
|
/.env
|
||||||
|
/.env.*
|
||||||
|
!/.env.example
|
||||||
|
/logs/
|
||||||
|
/runtime/
|
||||||
|
/dictionary/actions.jsonl
|
||||||
|
/dictionary/actions.index.json
|
||||||
|
*.log
|
||||||
@@ -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
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+20
@@ -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"
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
Executable
+9
@@ -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"
|
||||||
Executable
+20
@@ -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"
|
||||||
Executable
+13
@@ -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"
|
||||||
@@ -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