diff --git a/Cargo.lock b/Cargo.lock index e88057024..d137736f6 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-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index 8f59414c9..14b210c30 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); diff --git a/crates/openfang-runtime/src/prompt_builder.rs b/crates/openfang-runtime/src/prompt_builder.rs index dd28ae396..b0808a770 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`. @@ -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. @@ -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 @@ -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(); diff --git a/crates/openfang-types/Cargo.toml b/crates/openfang-types/Cargo.toml index 0c4ba7129..b0392bdba 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 25df9b005..0c69ea8af 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)] diff --git a/docs/README.md b/docs/README.md index 4ea86d4bf..8e3750efd 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 000000000..d0240f41c --- /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 000000000..f32d89b35 --- /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 + +