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
18 changes: 18 additions & 0 deletions src/agent-memory/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@ pub struct MemoryConfig {
/// intentional for append-style logging.
#[serde(default = "default_max_append_bytes")]
pub max_append_bytes: u64,
/// Maximum bytes accepted for the `hint` field in memory_observe.
/// Prevents a rogue/misconfigured model from injecting an arbitrarily
/// long hint that would bloat the YAML frontmatter block in observed
/// notes. Hints exceeding this cap are rejected with InvalidArgument.
/// Default 512 bytes.
#[serde(default = "default_max_hint_bytes")]
pub max_hint_bytes: u64,
}

impl Default for MemoryConfig {
Expand All @@ -98,6 +105,7 @@ impl Default for MemoryConfig {
max_read_bytes: default_max_read_bytes(),
max_write_bytes: default_max_write_bytes(),
max_append_bytes: default_max_append_bytes(),
max_hint_bytes: default_max_hint_bytes(),
}
}
}
Expand Down Expand Up @@ -327,6 +335,10 @@ fn default_max_append_bytes() -> u64 {
4 * 1_048_576 // 4 MiB
}

fn default_max_hint_bytes() -> u64 {
512
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PathsConfig {
Expand Down Expand Up @@ -588,6 +600,12 @@ impl AppConfig {
Err(e) => tracing::warn!("MEMORY_MAX_APPEND_BYTES={v:?} not a u64: {e}; ignoring"),
}
}
if let Ok(v) = std::env::var("MEMORY_MAX_HINT_BYTES") {
match v.parse::<u64>() {
Ok(n) => self.memory.max_hint_bytes = n,
Err(e) => tracing::warn!("MEMORY_MAX_HINT_BYTES={v:?} not a u64: {e}; ignoring"),
}
}
}

/// Resolve `~` and return the absolute base dir.
Expand Down
2 changes: 1 addition & 1 deletion src/agent-memory/src/service/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ impl MemoryService {
hint: Option<&str>,
memory_type: Option<&str>,
) -> Result<String> {
crate::tools::memory_observe(self, content, hint, memory_type)
crate::tools::memory_observe(self, content, hint, memory_type, &self.config.memory)
}

pub fn memory_get_context(&self, max_tokens: usize) -> Result<String> {
Expand Down
98 changes: 97 additions & 1 deletion src/agent-memory/src/tools/memory_observe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use chrono::Utc;
use serde::{Deserialize, Serialize};

use crate::audit::AuditEntry;
use crate::config::MemoryConfig;
use crate::error::{MemoryError, Result};
use crate::service::MemoryService;

Expand Down Expand Up @@ -81,6 +82,7 @@ pub fn memory_observe(
content: &str,
hint: Option<&str>,
memory_type: Option<&str>,
config: &MemoryConfig,
) -> Result<String> {
let ulid = ulid::Ulid::new();
let path = format!("notes/observed/{ulid}.md");
Expand All @@ -98,7 +100,23 @@ pub fn memory_observe(
body.push_str(&format!("type: {parsed_type}\n"));

if let Some(h) = hint {
let safe = h.replace('\n', " ");
// Enforce max_hint_bytes limit: a rogue or misconfigured model could
// inject an arbitrarily long hint, exhausting disk and JSON-RPC buffer
// budget. Reject early before we construct the YAML frontmatter body.
if h.len() as u64 > config.max_hint_bytes {
return Err(MemoryError::InvalidArgument(format!(
"hint length {} exceeds max_hint_bytes {}",
h.len(),
config.max_hint_bytes
)));
}
// hint is escaped for YAML frontmatter safety: newlines are replaced
// with spaces (they break YAML flow scalars), and the value is
// double-quoted with internal double-quotes and backslashes escaped.
// Without quoting, YAML special characters such as '#' (comment),
// ':' (key-value separator), and leading/trailing whitespace can
// corrupt the frontmatter block.
let safe = yaml_escape_hint(h);
body.push_str(&format!("hint: {safe}\n"));
}

Expand All @@ -116,6 +134,30 @@ pub fn memory_observe(
Ok(path)
}

/// Escape a hint string for safe inclusion as a YAML double-quoted value.
///
/// Strategy:
/// - Double-quote the value so `#`, `:`, and leading/trailing whitespace
/// cannot be misinterpreted by the YAML parser.
/// - Escape internal `"` → `\"` and `\` → `\\` so the YAML parser
/// correctly round-trips the value.
/// - Replace newlines with spaces (YAML flow scalars cannot span lines).
/// - Replace other ASCII control characters (\x00-\x1F) with spaces.
/// - Return `""` for an empty or all-whitespace hint.
fn yaml_escape_hint(hint: &str) -> String {
let escaped: String = hint
.chars()
.map(|c| match c {
'"' => "\\\"".to_string(),
'\\' => "\\\\".to_string(),
'\n' | '\r' => ' '.to_string(),
c if c.is_ascii_control() => ' '.to_string(),
other => other.to_string(),
})
.collect();
format!("\"{}\"", escaped)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -157,4 +199,58 @@ mod tests {
let mt: MemoryType = serde_json::from_str(r#""project""#).unwrap();
assert_eq!(mt, MemoryType::Project);
}

// ── yaml_escape_hint tests ──────────────────────────────────────

#[test]
fn escape_hint_with_hash() {
// '#' is a YAML comment token; double-quoting prevents truncation.
let result = yaml_escape_hint("fix #123 and #456");
assert_eq!(result, r#""fix #123 and #456""#);
}

#[test]
fn escape_hint_with_colon() {
// Unquoted colons trigger YAML key-value parsing.
let result = yaml_escape_hint("note: this has a colon");
assert_eq!(result, r#""note: this has a colon""#);
}

#[test]
fn escape_hint_with_quotes() {
let result = yaml_escape_hint(r#"she said "hello" and "goodbye""#);
assert_eq!(result, r#""she said \"hello\" and \"goodbye\"""#);
}

#[test]
fn escape_hint_with_backslash() {
let result = yaml_escape_hint(r"C:\Users\admin");
assert_eq!(result, r#""C:\\Users\\admin""#);
}

#[test]
fn escape_hint_multiline() {
// \r\n sequence: \r and \n each become a space, yielding two spaces.
let result = yaml_escape_hint("line1\nline2\r\nline3");
assert_eq!(result, "\"line1 line2 line3\"");
}

#[test]
fn escape_hint_empty() {
let result = yaml_escape_hint("");
assert_eq!(result, r#""""#);
}

#[test]
fn escape_hint_control_chars() {
// \x00-\x1F (except \n, \r handled above) become spaces.
let result = yaml_escape_hint("pre\x00mid\x1Fpost");
assert_eq!(result, r#""pre mid post""#);
}

#[test]
fn escape_hint_normal_text() {
let result = yaml_escape_hint("a simple observation note");
assert_eq!(result, r#""a simple observation note""#);
}
}