Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions crates/openfang-kernel/src/kernel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2278,6 +2278,9 @@ impl OpenFangKernel {
context_md: manifest.workspace.as_ref().and_then(|w| {
openfang_runtime::agent_context::load_context_md(w, manifest.cache_context)
}),
// Re-read RULES.md per turn so edits land on the next message.
// Tail-loaded as authoritative guidance; truncated by the loader.
rules_md: openfang_types::config::read_global_rules(),
};
manifest.model.system_prompt =
openfang_runtime::prompt_builder::build_system_prompt(&prompt_ctx);
Expand Down Expand Up @@ -2859,6 +2862,8 @@ impl OpenFangKernel {
context_md: manifest.workspace.as_ref().and_then(|w| {
openfang_runtime::agent_context::load_context_md(w, manifest.cache_context)
}),
// Re-read RULES.md per turn (tail-loaded authoritative guidance).
rules_md: openfang_types::config::read_global_rules(),
};
manifest.model.system_prompt =
openfang_runtime::prompt_builder::build_system_prompt(&prompt_ctx);
Expand Down
87 changes: 87 additions & 0 deletions crates/openfang-runtime/src/prompt_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ pub struct PromptContext {
/// Read per-turn by the kernel so external writers (cron jobs, integrations)
/// are reflected in the next LLM call. See issue #843.
pub context_md: Option<String>,
/// Global `RULES.md` content (workspace-level rules and template index).
///
/// Reloaded per-turn from disk so edits take effect on the next LLM call
/// without requiring an agent restart. Truncated to `RULES_MD_MAX_CHARS`
/// at load time. See `openfang_types::config::read_global_rules`.
pub rules_md: Option<String>,
}

/// Build the complete system prompt from a `PromptContext`.
Expand Down Expand Up @@ -209,6 +215,18 @@ pub fn build_system_prompt(ctx: &PromptContext) -> String {
}
}

// Section 14.5 — Global RULES.md (workspace-level rules + template index).
// Re-read per turn by the kernel from `~/.openfang/RULES.md` so edits take
// effect immediately. Truncated at load time to `RULES_MD_MAX_CHARS`.
// These rules OVERRIDE earlier guidance in this prompt EXCEPT the Safety
// section, which is non-negotiable.
if let Some(ref rules) = ctx.rules_md {
let trimmed = rules.trim();
if !trimmed.is_empty() {
sections.push(build_rules_section(trimmed));
}
}

// Section 15 — Live agent context (`context.md`). Re-read per turn so
// external writers (e.g. cron jobs refreshing live data) show up on the
// very next message. See issue #843.
Expand Down Expand Up @@ -479,6 +497,23 @@ fn build_peer_agents_section(self_name: &str, peers: &[(String, String, String)]
out
}

/// Build the global RULES.md section (Section 14.5).
///
/// RULES.md is workspace-level user-authored guidance (conventions, template
/// index for orchestrators, project-wide policies). It is reloaded per-turn so
/// edits take effect on the very next message. The framing makes RULES.md
/// authoritative over earlier guidance in this prompt, with one carveout: the
/// Safety section above is non-negotiable.
fn build_rules_section(rules: &str) -> String {
format!(
"## Global Rules (RULES.md)\n\
The following rules are loaded from `~/.openfang/RULES.md` and reloaded \
every turn. They OVERRIDE earlier guidance in this system prompt where \
they conflict, EXCEPT the Safety section above, which is non-negotiable.\n\n\
{rules}"
)
}

/// Static safety section.
const SAFETY_SECTION: &str = "\
## Safety
Expand Down Expand Up @@ -970,6 +1005,58 @@ mod tests {
assert!(!prompt.contains("## Live Context"));
}

#[test]
fn test_rules_md_section_included() {
let mut ctx = basic_ctx();
ctx.rules_md = Some("- Always use tabs.\n- Commit messages in lowercase.".to_string());
let prompt = build_system_prompt(&ctx);
assert!(prompt.contains("## Global Rules (RULES.md)"));
assert!(prompt.contains("Always use tabs."));
assert!(prompt.contains("OVERRIDE earlier guidance"));
assert!(prompt.contains("Safety section above"));
}

#[test]
fn test_rules_md_section_omitted_when_none_or_blank() {
let mut ctx = basic_ctx();
ctx.rules_md = None;
let prompt = build_system_prompt(&ctx);
assert!(!prompt.contains("## Global Rules"));

ctx.rules_md = Some(" \n\n ".to_string());
let prompt = build_system_prompt(&ctx);
assert!(!prompt.contains("## Global Rules"));
}

#[test]
fn test_rules_md_ordering_after_safety_before_live_context() {
let mut ctx = basic_ctx();
ctx.rules_md = Some("workspace rule body".to_string());
ctx.context_md = Some("live data body".to_string());
let prompt = build_system_prompt(&ctx);
let safety_pos = prompt.find("## Safety").unwrap();
let rules_pos = prompt.find("## Global Rules").unwrap();
let live_pos = prompt.find("## Live Context").unwrap();
assert!(safety_pos < rules_pos, "RULES.md must come after Safety");
assert!(
rules_pos < live_pos,
"RULES.md must come before Live Context"
);
}

#[test]
fn test_rules_md_present_for_subagents() {
// Subagents still inherit workspace rules (they're spawned to do work
// in the same workspace). The orchestration template index in RULES.md
// is irrelevant for them, but the rules themselves apply.
let mut ctx = basic_ctx();
ctx.is_subagent = true;
ctx.rules_md = Some("inherited rule".to_string());
let prompt = build_system_prompt(&ctx);
assert!(prompt.contains("## Global Rules"));
assert!(prompt.contains("inherited rule"));
}

#[test]
fn test_workspace_in_persona() {
let mut ctx = basic_ctx();
Expand Down
1 change: 1 addition & 0 deletions crates/openfang-types/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ bitflags = "2"

[dev-dependencies]
rmp-serde = { workspace = true }
tempfile = { workspace = true }
108 changes: 108 additions & 0 deletions crates/openfang-types/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1675,6 +1675,114 @@ fn openfang_home_dir() -> PathBuf {
.join(".openfang")
}

/// Maximum length (in chars) of `RULES.md` content injected into prompts.
///
/// Matches the cap used for `AGENTS.md`. Content beyond this is truncated.
pub const RULES_MD_MAX_CHARS: usize = 2000;

/// Read the global user-authored rules file from `~/.openfang/RULES.md`.
///
/// Returns `None` when the file is missing, unreadable, or empty after trim.
/// Content is truncated to [`RULES_MD_MAX_CHARS`] characters. Errors are
/// swallowed silently — a missing or broken rules file must never break the
/// prompt build.
///
/// Re-read on every prompt assembly so user edits land on the next turn
/// without a daemon bounce (mirrors `context.md` semantics).
pub fn read_global_rules() -> Option<String> {
let path = openfang_home_dir().join("RULES.md");
let raw = std::fs::read_to_string(&path).ok()?;
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.chars().count() > RULES_MD_MAX_CHARS {
Some(trimmed.chars().take(RULES_MD_MAX_CHARS).collect())
} else {
Some(trimmed.to_string())
}
}

#[cfg(test)]
mod global_rules_tests {
use super::*;
use std::sync::{Mutex, MutexGuard, OnceLock};

/// Serialize env-var mutation across tests in this module. `cargo test`
/// runs tests in parallel and `OPENFANG_HOME` is process-global.
fn env_lock() -> MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|p| p.into_inner())
}

/// Helper: point `OPENFANG_HOME` at a unique tempdir for the duration of
/// the test. Holds `env_lock` so concurrent tests can't clobber each
/// other's env var.
struct HomeGuard {
_dir: tempfile::TempDir,
prev: Option<String>,
_lock: MutexGuard<'static, ()>,
}

impl HomeGuard {
fn new() -> (Self, PathBuf) {
let lock = env_lock();
let dir = tempfile::tempdir().expect("tempdir");
let prev = std::env::var("OPENFANG_HOME").ok();
std::env::set_var("OPENFANG_HOME", dir.path());
let path = dir.path().to_path_buf();
(
Self {
_dir: dir,
prev,
_lock: lock,
},
path,
)
}
}

impl Drop for HomeGuard {
fn drop(&mut self) {
match &self.prev {
Some(v) => std::env::set_var("OPENFANG_HOME", v),
None => std::env::remove_var("OPENFANG_HOME"),
}
}
}

#[test]
fn returns_none_when_file_missing() {
let (_guard, _home) = HomeGuard::new();
assert!(read_global_rules().is_none());
}

#[test]
fn returns_none_when_file_empty() {
let (_guard, home) = HomeGuard::new();
std::fs::write(home.join("RULES.md"), " \n\t\n").unwrap();
assert!(read_global_rules().is_none());
}

#[test]
fn returns_trimmed_content_when_present() {
let (_guard, home) = HomeGuard::new();
std::fs::write(home.join("RULES.md"), "\n hello rules \n").unwrap();
assert_eq!(read_global_rules().as_deref(), Some("hello rules"));
}

#[test]
fn truncates_oversized_content() {
let (_guard, home) = HomeGuard::new();
let big = "a".repeat(RULES_MD_MAX_CHARS + 500);
std::fs::write(home.join("RULES.md"), &big).unwrap();
let got = read_global_rules().expect("some");
assert_eq!(got.chars().count(), RULES_MD_MAX_CHARS);
}
}

/// Default LLM model configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
Expand Down
2 changes: 2 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Welcome to the OpenFang documentation. OpenFang is the open-source Agent Operati
|-------|-------------|
| [Getting Started](getting-started.md) | Installation, first agent, first chat session |
| [Configuration](configuration.md) | Complete `config.toml` reference with every field |
| [Global Rules](global-rules.md) | `~/.openfang/RULES.md` — per-user authoritative overlay loaded every turn |
| [CLI Reference](cli-reference.md) | Every command and subcommand with examples |
| [Troubleshooting](troubleshooting.md) | Common issues, FAQ, diagnostics |

Expand Down Expand Up @@ -86,6 +87,7 @@ openfang init && openfang start
| Path | Description |
|------|-------------|
| `~/.openfang/config.toml` | Main configuration file |
| `~/.openfang/RULES.md` | Per-user authoritative overlay (see [global-rules.md](global-rules.md)) |
| `~/.openfang/data/openfang.db` | SQLite database |
| `~/.openfang/skills/` | Installed skills |
| `~/.openfang/daemon.json` | Daemon PID and port info |
Expand Down
Loading