fix(runtime): re-read agent context.md per turn so external updates take effect (#843) (#1075)

When an agent had a context.md file updated externally (e.g. a cron job
refreshing live market data), the updated content never reached the LLM
during an active session. The file was effectively cached for the
lifetime of the conversation.

The runtime now reads context.md from the agent workspace once per turn,
right before the system prompt is built, and injects it as a dedicated
'Live Context' section. Agents that still want the old cache-at-start
behaviour can opt back in with 'cache_context = true' on the manifest.

- new openfang-runtime::agent_context module with a small per-path cache
- if a re-read fails after a previous success, fall back to the cached
  content with a warning instead of dropping context mid-conversation
- new PromptContext.context_md field wired up in both kernel streaming
  and non-streaming paths
- one small disk read per agent turn (not per streaming token); file
  size capped at 32 KB like the other identity files

Made-with: Cursor
This commit is contained in:
Jaber Jaber
2026-04-17 22:46:05 +03:00
committed by GitHub
parent e2b0a54720
commit 6ab07d155e
8 changed files with 289 additions and 0 deletions

View File

@@ -335,6 +335,7 @@ mod tests {
exec_policy: None,
tool_allowlist: vec![],
tool_blocklist: vec![],
cache_context: false,
},
state,
mode: AgentMode::default(),

View File

@@ -2015,6 +2015,12 @@ impl OpenFangKernel {
),
sender_id,
sender_name,
// Re-read context.md per turn by default so external writers
// (cron jobs, integrations) reach the LLM on the next message.
// Opt out via `cache_context = true` on the manifest. (#843)
context_md: manifest.workspace.as_ref().and_then(|w| {
openfang_runtime::agent_context::load_context_md(w, manifest.cache_context)
}),
};
manifest.model.system_prompt =
openfang_runtime::prompt_builder::build_system_prompt(&prompt_ctx);
@@ -2576,6 +2582,10 @@ impl OpenFangKernel {
),
sender_id,
sender_name,
// Re-read context.md per turn by default (#843).
context_md: manifest.workspace.as_ref().and_then(|w| {
openfang_runtime::agent_context::load_context_md(w, manifest.cache_context)
}),
};
manifest.model.system_prompt =
openfang_runtime::prompt_builder::build_system_prompt(&prompt_ctx);
@@ -6874,6 +6884,7 @@ mod tests {
exec_policy: None,
tool_allowlist: vec![],
tool_blocklist: vec![],
cache_context: false,
};
manifest.capabilities.tools = vec!["file_read".to_string(), "web_fetch".to_string()];
manifest.capabilities.agent_spawn = true;
@@ -6911,6 +6922,7 @@ mod tests {
exec_policy: None,
tool_allowlist: vec![],
tool_blocklist: vec![],
cache_context: false,
}
}

View File

@@ -395,6 +395,7 @@ mod tests {
exec_policy: None,
tool_allowlist: vec![],
tool_blocklist: vec![],
cache_context: false,
},
state: AgentState::Created,
mode: AgentMode::default(),

View File

@@ -182,6 +182,7 @@ impl SetupWizard {
exec_policy: None,
tool_allowlist: vec![],
tool_blocklist: vec![],
cache_context: false,
};
let skills_to_install: Vec<String> = intent

View File

@@ -0,0 +1,225 @@
//! Per-turn agent context loader for external `context.md` files.
//!
//! Some agents depend on a `context.md` file updated by external tools (e.g. a
//! cron job that writes live market data, or a script that refreshes project
//! state). Before issue #843 this file was read once when the session started
//! and then cached for the lifetime of the conversation, so external updates
//! never reached the LLM.
//!
//! The default behaviour is now a small disk read per turn when the prompt is
//! assembled. Agents that depend on the old behaviour can opt back in via the
//! `cache_context` flag on their manifest.
//!
//! This module intentionally does not participate in per-token streaming — it
//! is called once per agent turn, right before the system prompt is built.
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::{collections::HashMap, fs};
use tracing::{debug, warn};
/// Maximum size of `context.md` to inject into the prompt (32 KB).
///
/// Matches the cap used by [`crate::workspace_context`] and the kernel's
/// identity-file reader so a runaway file cannot blow up the prompt.
const MAX_CONTEXT_BYTES: u64 = 32_768;
/// Filename that agents use for per-turn refreshable context.
pub const CONTEXT_FILENAME: &str = "context.md";
/// In-memory cache of the last successful read for each workspace.
///
/// Used for two purposes:
/// 1. When `cache_context = true`, the first successful read is returned on
/// every subsequent call.
/// 2. When `cache_context = false` and a re-read fails on disk (e.g. the file
/// was temporarily replaced by an external writer), we fall back to the
/// previous content instead of dropping context mid-conversation.
fn cache() -> &'static Mutex<HashMap<PathBuf, String>> {
static CACHE: OnceLock<Mutex<HashMap<PathBuf, String>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Load the agent's `context.md` for this turn.
///
/// Returns the current on-disk content, or — if the read fails after a
/// previous success — the cached content with a warning. Returns `None` when
/// no context.md has ever been seen for this workspace.
///
/// When `cache_context` is true the first successful read is stored and
/// returned verbatim on every future call. Callers pass the flag straight from
/// `AgentManifest::cache_context`.
pub fn load_context_md(workspace: &Path, cache_context: bool) -> Option<String> {
let path = workspace.join(CONTEXT_FILENAME);
if cache_context {
if let Some(cached) = get_cached(&path) {
return Some(cached);
}
}
match read_capped(&path) {
Ok(Some(content)) => {
store_cached(&path, &content);
Some(content)
}
Ok(None) => {
// File is absent or empty — do not serve a stale cache for a
// deleted file unless the caller explicitly opted into caching.
if cache_context {
get_cached(&path)
} else {
None
}
}
Err(e) => {
if let Some(prev) = get_cached(&path) {
warn!(
path = %path.display(),
error = %e,
"Failed to re-read context.md; falling back to cached content"
);
Some(prev)
} else {
debug!(path = %path.display(), error = %e, "context.md unreadable and no cache");
None
}
}
}
}
fn get_cached(path: &Path) -> Option<String> {
cache()
.lock()
.ok()
.and_then(|guard| guard.get(path).cloned())
}
fn store_cached(path: &Path, content: &str) {
if let Ok(mut guard) = cache().lock() {
guard.insert(path.to_path_buf(), content.to_string());
}
}
/// Read the file, returning Ok(None) if it is missing or empty, and
/// Ok(Some(...)) if it has usable content. Oversized files are truncated to
/// [`MAX_CONTEXT_BYTES`] so prompt size remains bounded.
fn read_capped(path: &Path) -> std::io::Result<Option<String>> {
let meta = match fs::metadata(path) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
if !meta.is_file() {
return Ok(None);
}
let content = fs::read_to_string(path)?;
if content.trim().is_empty() {
return Ok(None);
}
if meta.len() > MAX_CONTEXT_BYTES {
let truncated = crate::str_utils::safe_truncate_str(&content, MAX_CONTEXT_BYTES as usize);
return Ok(Some(truncated.to_string()));
}
Ok(Some(content))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn fresh_workspace(tag: &str) -> PathBuf {
// Unique temp dir per test to avoid cross-test cache pollution.
let dir = std::env::temp_dir().join(format!(
"openfang_ctx_{}_{}",
tag,
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn reread_picks_up_external_update() {
let ws = fresh_workspace("reread");
let path = ws.join(CONTEXT_FILENAME);
fs::write(&path, "initial content A").unwrap();
let first = load_context_md(&ws, false).unwrap();
assert!(first.contains("initial content A"));
// External writer updates the file (simulates the cron case from #843).
{
let mut f = fs::File::create(&path).unwrap();
f.write_all(b"updated content B").unwrap();
}
let second = load_context_md(&ws, false).unwrap();
assert!(second.contains("updated content B"));
assert!(!second.contains("initial content A"));
let _ = fs::remove_dir_all(&ws);
}
#[test]
fn cache_context_true_freezes_first_read() {
let ws = fresh_workspace("cache");
let path = ws.join(CONTEXT_FILENAME);
fs::write(&path, "frozen A").unwrap();
let first = load_context_md(&ws, true).unwrap();
assert!(first.contains("frozen A"));
fs::write(&path, "never seen B").unwrap();
let second = load_context_md(&ws, true).unwrap();
assert_eq!(first, second);
assert!(!second.contains("never seen B"));
let _ = fs::remove_dir_all(&ws);
}
#[test]
fn missing_file_returns_none() {
let ws = fresh_workspace("missing");
assert!(load_context_md(&ws, false).is_none());
assert!(load_context_md(&ws, true).is_none());
let _ = fs::remove_dir_all(&ws);
}
#[test]
fn read_failure_falls_back_to_cache() {
let ws = fresh_workspace("fallback");
let path = ws.join(CONTEXT_FILENAME);
fs::write(&path, "cached payload").unwrap();
let first = load_context_md(&ws, false).unwrap();
assert!(first.contains("cached payload"));
// Write bytes that are not valid UTF-8 so read_to_string returns an
// IO error. This simulates a transient read failure while the cron
// job is mid-rewrite.
{
let mut f = fs::File::create(&path).unwrap();
f.write_all(&[0xff, 0xfe, 0xfd, 0x80, 0x81]).unwrap();
}
let second = load_context_md(&ws, false);
assert_eq!(second.as_deref(), Some("cached payload"));
let _ = fs::remove_dir_all(&ws);
}
#[test]
fn empty_file_treated_as_absent() {
let ws = fresh_workspace("empty");
let path = ws.join(CONTEXT_FILENAME);
fs::write(&path, " \n\n ").unwrap();
assert!(load_context_md(&ws, false).is_none());
let _ = fs::remove_dir_all(&ws);
}
}

View File

@@ -8,6 +8,7 @@
pub const USER_AGENT: &str = "openfang/0.3.48";
pub mod a2a;
pub mod agent_context;
pub mod agent_loop;
pub mod apply_patch;
pub mod audit;

View File

@@ -61,6 +61,11 @@ pub struct PromptContext {
pub sender_id: Option<String>,
/// Sender display name.
pub sender_name: Option<String>,
/// Current on-disk `context.md` content for the agent (see `agent_context`).
///
/// 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>,
}
/// Build the complete system prompt from a `PromptContext`.
@@ -204,6 +209,19 @@ pub fn build_system_prompt(ctx: &PromptContext) -> String {
}
}
// 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.
if let Some(ref live) = ctx.context_md {
let trimmed = live.trim();
if !trimmed.is_empty() {
sections.push(format!(
"## Live Context\nThe following context is refreshed from `context.md` each turn and may change between messages.\n\n{}",
cap_str(trimmed, 8000)
));
}
}
sections.join("\n\n")
}
@@ -929,6 +947,28 @@ mod tests {
assert!(prompt.contains("A helpful agent"));
}
#[test]
fn test_context_md_section_included() {
let mut ctx = basic_ctx();
ctx.context_md = Some("BTCUSD: 67000\nETHUSD: 3400".to_string());
let prompt = build_system_prompt(&ctx);
assert!(prompt.contains("## Live Context"));
assert!(prompt.contains("BTCUSD: 67000"));
assert!(prompt.contains("ETHUSD: 3400"));
}
#[test]
fn test_context_md_section_omitted_when_empty_or_none() {
let mut ctx = basic_ctx();
ctx.context_md = None;
let prompt = build_system_prompt(&ctx);
assert!(!prompt.contains("## Live Context"));
ctx.context_md = Some(" \n\n ".to_string());
let prompt = build_system_prompt(&ctx);
assert!(!prompt.contains("## Live Context"));
}
#[test]
fn test_workspace_in_persona() {
let mut ctx = basic_ctx();

View File

@@ -491,6 +491,12 @@ pub struct AgentManifest {
/// Tool blocklist — these tools are excluded (applied after allowlist).
#[serde(default, deserialize_with = "crate::serde_compat::vec_lenient")]
pub tool_blocklist: Vec<String>,
/// If true, the agent's `context.md` is read once at session start and
/// reused. Default is `false`: the runtime re-reads `context.md` before
/// every turn so external writers (cron jobs, integrations) reach the LLM
/// on the next message. See issue #843.
#[serde(default)]
pub cache_context: bool,
}
fn default_true() -> bool {
@@ -525,6 +531,7 @@ impl Default for AgentManifest {
exec_policy: None,
tool_allowlist: Vec::new(),
tool_blocklist: Vec::new(),
cache_context: false,
}
}
}
@@ -782,6 +789,7 @@ mod tests {
exec_policy: None,
tool_allowlist: Vec::new(),
tool_blocklist: Vec::new(),
cache_context: false,
};
let json = serde_json::to_string(&manifest).unwrap();
let deserialized: AgentManifest = serde_json::from_str(&json).unwrap();