From d1271c45699b3d11c8d43bc330c3698acecf012b Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Wed, 13 May 2026 15:06:16 -0700 Subject: [PATCH 1/5] feat(types): add read_global_rules() loader with 2KB cap Introduce read_global_rules() in openfang-types::config that reads $OPENFANG_HOME/RULES.md, trims it, and returns None if absent or blank. Output is truncated to RULES_MD_MAX_CHARS (2000) so a runaway file can't balloon the system prompt. Companion to the per-turn reload path: the kernel calls this on every turn so edits to RULES.md land on the next message without restart. Includes 4 unit tests: missing file, empty/whitespace, present, oversized. --- Cargo.lock | 1 + crates/openfang-types/Cargo.toml | 1 + crates/openfang-types/src/config.rs | 108 ++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index e880570247..d137736f60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4295,6 +4295,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "tempfile", "thiserror 2.0.18", "toml 0.9.12+spec-1.1.0", "uuid", diff --git a/crates/openfang-types/Cargo.toml b/crates/openfang-types/Cargo.toml index 0c4ba71290..b0392bdba3 100644 --- a/crates/openfang-types/Cargo.toml +++ b/crates/openfang-types/Cargo.toml @@ -22,3 +22,4 @@ bitflags = "2" [dev-dependencies] rmp-serde = { workspace = true } +tempfile = { workspace = true } diff --git a/crates/openfang-types/src/config.rs b/crates/openfang-types/src/config.rs index 25df9b0059..0c69ea8afd 100644 --- a/crates/openfang-types/src/config.rs +++ b/crates/openfang-types/src/config.rs @@ -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 { + 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> = 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, + _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)] From 3746232ad7aab9a2500b222869b9c39879876258 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Wed, 13 May 2026 15:06:31 -0700 Subject: [PATCH 2/5] feat(runtime): add rules_md field to PromptContext Adds Option rules_md alongside context_md. Field is per-turn reloadable; the kernel will populate it from read_global_rules() on each turn. Default is None so existing call sites keep compiling via #[derive(Default)]. --- crates/openfang-runtime/src/prompt_builder.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/openfang-runtime/src/prompt_builder.rs b/crates/openfang-runtime/src/prompt_builder.rs index dd28ae396e..982acf178f 100644 --- a/crates/openfang-runtime/src/prompt_builder.rs +++ b/crates/openfang-runtime/src/prompt_builder.rs @@ -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, + /// 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, } /// Build the complete system prompt from a `PromptContext`. From 20e022fe26ea91438b350d120c51cb09db54e259 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Wed, 13 May 2026 15:06:47 -0700 Subject: [PATCH 3/5] feat(runtime): render RULES.md section at position 14.5 Insert a new ## Global Rules (RULES.md) section into build_system_prompt between Workspace Context (14) and Live Context (15). The framing makes the rules authoritative over earlier guidance, with one explicit carveout: the Safety section above remains non-negotiable. Section is elided when ctx.rules_md is None or trims to empty so the prompt stays clean for users who never write a RULES.md. Includes 4 unit tests covering presence, blank/None elision, ordering relative to Safety + Live Context, and subagent inheritance. --- crates/openfang-runtime/src/prompt_builder.rs | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/crates/openfang-runtime/src/prompt_builder.rs b/crates/openfang-runtime/src/prompt_builder.rs index 982acf178f..b0808a7700 100644 --- a/crates/openfang-runtime/src/prompt_builder.rs +++ b/crates/openfang-runtime/src/prompt_builder.rs @@ -215,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. @@ -485,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 @@ -976,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(); From 7ba629b4d922bad90e635ef18323f161fea73744 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Wed, 13 May 2026 15:06:53 -0700 Subject: [PATCH 4/5] feat(kernel): wire RULES.md loader into per-turn prompt build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Populate PromptContext.rules_md via openfang_types::config::read_global_rules() at both PromptContext construction sites in kernel.rs. Re-read on every turn alongside context_md so edits to ~/.openfang/RULES.md take effect on the very next message — no daemon restart required. --- crates/openfang-kernel/src/kernel.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index 8f59414c97..14b210c30c 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -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); @@ -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); From fd497d56acea4051d14aaf0f6313c7618705b832 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Wed, 13 May 2026 15:06:58 -0700 Subject: [PATCH 5/5] docs: document global RULES.md mechanism + example template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/global-rules.md: full reference for the per-turn RULES.md overlay — contract, position in the system prompt (14.5), authority + Safety carveout, subagent inheritance, gotchas, worked example. - docs/templates/RULES.md.example: starter template with common patterns (style preferences, channel etiquette, escalation rules) all commented out so users uncomment what they want. - docs/README.md: index updated under Getting Started and Important Paths. --- docs/README.md | 2 + docs/global-rules.md | 126 ++++++++++++++++++++++++++++++++ docs/templates/RULES.md.example | 69 +++++++++++++++++ 3 files changed, 197 insertions(+) create mode 100644 docs/global-rules.md create mode 100644 docs/templates/RULES.md.example diff --git a/docs/README.md b/docs/README.md index 4ea86d4bf9..8e3750efd5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 | @@ -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 | diff --git a/docs/global-rules.md b/docs/global-rules.md new file mode 100644 index 0000000000..d0240f41c7 --- /dev/null +++ b/docs/global-rules.md @@ -0,0 +1,126 @@ +# Global Rules (`RULES.md`) + +A per-user authoritative overlay that lets you steer every agent in your OpenFang instance without editing agent manifests, channel configs, or the daemon binary. Drop a Markdown file at `~/.openfang/RULES.md`, edit freely, and your changes land on the very next message. + +--- + +## Table of Contents + +- [What It Is](#what-it-is) +- [Where It Lives](#where-it-lives) +- [Contract](#contract) +- [Position in the System Prompt](#position-in-the-system-prompt) +- [Subagent Inheritance](#subagent-inheritance) +- [Authority](#authority) +- [Getting Started](#getting-started) +- [Worked Example](#worked-example) +- [Gotchas](#gotchas) + +--- + +## What It Is + +`RULES.md` is a single Markdown file you control. Its contents are injected verbatim into every agent's system prompt on every turn, framed as authoritative user-level guidance. + +It is **not** a config file, **not** a script, and **not** scoped to a single agent. Think of it as the operating manual for your fleet — the place to capture preferences, etiquette, escalation paths, and any standing instructions you would otherwise re-type every conversation. + +## Where It Lives + +``` +~/.openfang/RULES.md +``` + +(On systems where `OPENFANG_HOME` is set, e.g. inside the Docker image, the file lives at `$OPENFANG_HOME/RULES.md`.) + +It sits alongside the other top-level pieces of your OpenFang home: + +``` +~/.openfang/ +├── RULES.md ← you are here +├── config.toml +├── agents/ +├── workspaces/ +└── data/ +``` + +## Contract + +- **Re-read every turn.** The file is loaded fresh on each model invocation; you do not need to restart the daemon for edits to take effect. Save the file, send your next message, and the new rules are in play. +- **Truncated at load time.** Contents are capped at **2,000 characters** before injection. Anything past the cap is silently dropped. Keep `RULES.md` tight — it is overlay, not encyclopedia. +- **Elided when empty.** If the file is missing, empty, or whitespace-only, the entire section is omitted from the prompt. No empty header, no placeholder. +- **Trimmed.** Leading and trailing whitespace are stripped before injection. + +## Position in the System Prompt + +The rules are injected as a labelled section sitting between the per-agent workspace context and the live runtime context: + +``` +… +14. Workspace Context (the agent's own files) +14.5 Global User Rules ← RULES.md +15. Live Context (date, user, peers, memory) +… +``` + +This placement means the rules see every preceding instruction (identity, persona, skills, workspace) and can countermand them, but they cannot countermand the live runtime block — useful for keeping things like the current date authoritative. + +## Subagent Inheritance + +Subagents spawned in the same workspace inherit the same `RULES.md`. There is no per-subagent override — a single source of truth for the whole tree. + +## Authority + +Within the prompt, `RULES.md` is framed as authoritative over earlier sections (persona, agent-specific guidelines, default behaviors) **with one carve-out**: the Safety block always wins. You cannot use `RULES.md` to disable safety guardrails, override irreversible-action confirmation, or bypass the agent's refusal posture. + +In practice this means: + +- ✅ Style and tone preferences +- ✅ Channel etiquette +- ✅ Escalation rules ("ask before X", "always confirm Y") +- ✅ Defaults ("prefer Z when ambiguous") +- ❌ Disabling safety confirmations +- ❌ Bypassing audit or logging behavior + +## Getting Started + +A starter template ships with the repository: + +```bash +cp docs/templates/RULES.md.example ~/.openfang/RULES.md +$EDITOR ~/.openfang/RULES.md +``` + +Trim it to what you actually want — the template is deliberately verbose so you can see what shapes the rules can take. Anything you leave commented out has no effect. + +## Worked Example + +A short, real-world `RULES.md`: + +```markdown +# My Rules + +## Style +- Be concise. Lead with the answer. +- No filler ("Great question!", "I'd be happy to help!"). + +## Channel etiquette +- In `#code-system`, keep messages under ~10 lines unless I ask for detail. +- Use code fences for any path or command. + +## Escalation +- Never push to `origin/main` without explicit confirmation. +- Confirm before any `rm -rf`, `git reset --hard`, or force-push. +``` + +That's it. Save, send a message, and every agent in the fleet picks it up on the next turn. + +## Gotchas + +- **2,000-character cap is per-file, not per-section.** If you blow past it, the *tail* of the file is dropped. Put the most important rules first. +- **No templating.** `RULES.md` is plain Markdown — no variable substitution, no includes, no conditionals. +- **No per-agent scoping.** If you need agent-specific rules, put them in the agent manifest or workspace files, not here. +- **Edits are immediate but not retroactive.** In-flight turns finish with the prompt they started with; the new contents apply from the next turn onward. + +--- + +For the implementation details (loader, prompt builder, kernel wiring), see the source in `crates/openfang-types/src/config.rs` (`read_global_rules`) and `crates/openfang-runtime/src/prompt_builder.rs` (`build_rules_section`). diff --git a/docs/templates/RULES.md.example b/docs/templates/RULES.md.example new file mode 100644 index 0000000000..f32d89b35e --- /dev/null +++ b/docs/templates/RULES.md.example @@ -0,0 +1,69 @@ +# Global Rules — Example Template +# +# Copy to ~/.openfang/RULES.md and edit. Anything you leave commented out has +# no effect. See docs/global-rules.md for the full mechanism (load timing, +# 2000-char cap, prompt position, authority vs. Safety). +# +# Keep this file tight. Tail past 2000 characters is silently dropped, so put +# the rules that matter most at the top. + +## About me + + + +## Style + + + +## Channel etiquette + + + +## Escalation & confirmation + + + +## Defaults & preferences + + + +## Memory & context + + + +## Things I never want + +