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
16 changes: 8 additions & 8 deletions docs/design-docs/working-memory-triage.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ Findings from CodeRabbit review + bug reports. Tracking resolution before merge.
- [x] **R3 — Don't exclude participant-role facts yet** (`prompts/en/cortex_knowledge_synthesis.md.j2:21`)
Exclusion of "The user is the CEO" drops participant context with nowhere else to live until Phase 6 ships. **Fixed in this slice:** knowledge synthesis now preserves concise participant/user role facts when they affect future routing, authority, relationships, or interpretation.

- [ ] **R4 — Raw worker task in working memory** (`src/agent/channel_dispatch.rs:596`)
`task` from user input persisted verbatim; could capture secrets/PII. Truncate and scrub.
- [x] **R4 — Raw worker task in working memory** (`src/agent/channel_dispatch.rs:596`)
`task` from user input persisted verbatim; could capture secrets/PII. **Fixed in this slice:** worker-spawn task text is now redacted and bounded via shared working-memory scrub helpers.

- [ ] **R5 — Dirty flag only bumps on merges** (`src/agent/cortex.rs:1958`)
Prunes and decays also change the memory set but don't trigger knowledge synthesis re-gen. Add `report.pruned > 0 || report.decayed > 0`. **Partial in PR #570:** prunes and merges now dirty synthesis; decay remains intentionally importance-only and needs a follow-up decision.
Expand All @@ -41,20 +41,20 @@ Findings from CodeRabbit review + bug reports. Tracking resolution before merge.
- [x] **R11 — Unsynthesized yesterday events dropped** (`src/agent/cortex.rs:2916`)
Raw events that didn't hit count/time trigger before midnight are lost from daily summary. Roll them into the summary. **Fixed:** daily summary now fetches all raw events, filters to the unsynthesized tail after the last intra-day synthesis, and includes them in the LLM input.

- [ ] **R12 — Silent error swallowing in inspect_prompt** (`src/api/channels.rs:649`)
`unwrap_or_default()` / `.ok()` hides DB/template errors. Log and propagate per coding guidelines.
- [x] **R12 — Silent error swallowing in inspect_prompt** (`src/api/channels.rs:649`)
`unwrap_or_default()` / `.ok()` hides DB/template errors. **Fixed in this slice:** inspect prompt now logs and returns internal errors when DB/template rendering fails.

- [ ] **R13 — Raw error strings in working memory** (`src/cron/scheduler.rs:386`)
Full error text persisted; could contain sensitive internals. Emit redacted summary only.
- [x] **R13 — Raw error strings in working memory** (`src/cron/scheduler.rs:386`)
Full error text persisted; could contain sensitive internals. **Fixed in this slice:** cron error events now persist scrubbed and bounded summaries (including encoded leak fail-closed redaction).

- [ ] **R14 — Timezone fallback drops valid `cron_timezone`** (`src/main.rs:2559`)
If `user_timezone` is present but unparseable, `cron_timezone` is never tried. Parse each independently.

- [x] **R15 — UTF-8 panic on topic truncation** (`src/memory/working.rs:739`)
Byte-index slice at 80 can split multibyte chars. **Fixed:** `floor_char_boundary(80)`.

- [ ] **R16 — Task update event always says "status change"** (`src/tools/task_update.rs:246`)
Every update emits `"updated to <status>"` even for title/description edits. Compute actual delta.
- [x] **R16 — Task update event always says "status change"** (`src/tools/task_update.rs:246`)
Every update emits `"updated to <status>"` even for title/description edits. **Fixed in this slice:** working-memory task updates now describe actual field deltas and preserve status-only wording when only status changed.

## Live Observations (from prompt inspect, March 19)

Expand Down
150 changes: 112 additions & 38 deletions src/agent/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,31 @@ fn branch_working_memory_event_summary(
)
}

fn memory_persistence_trigger_kind(
message_count: usize,
elapsed_secs: u64,
event_count_since_last: Option<usize>,
wm_config: &crate::config::WorkingMemoryConfig,
) -> Option<&'static str> {
let message_trigger = message_count >= wm_config.persistence_message_threshold;
let time_trigger =
message_count > 0 && elapsed_secs >= wm_config.persistence_time_threshold_secs;
let density_trigger = !message_trigger
&& !time_trigger
&& event_count_since_last
.is_some_and(|count| count >= wm_config.persistence_event_density_threshold);

if message_trigger {
Some("message_count")
} else if time_trigger {
Some("time")
} else if density_trigger {
Some("event_density")
} else {
None
}
}

fn parse_branch_cancellation_reason(conclusion: &str) -> Option<&str> {
let trimmed = conclusion.trim();
if let Some(rest) = trimmed.strip_prefix(BRANCH_CANCELLED_PREFIX) {
Expand Down Expand Up @@ -3706,44 +3731,39 @@ impl Channel {

let wm_config = **self.deps.runtime_config.working_memory.load();
let elapsed = self.last_persistence_at.elapsed();
let elapsed_secs = elapsed.as_secs();
let trigger = match memory_persistence_trigger_kind(
self.message_count,
elapsed_secs,
None,
&wm_config,
) {
Some(kind) => kind,
None => {
let since = chrono::Utc::now() - chrono::Duration::seconds(elapsed_secs as i64);
let event_count_since_last = match self
.deps
.working_memory
.count_events_since(self.id.as_ref(), since)
.await
{
Ok(count) => Some(count as usize),
Err(error) => {
tracing::debug!(%error, "event density check failed, skipping");
None
}
};

// Trigger 1: Message count threshold.
let message_trigger = self.message_count >= wm_config.persistence_message_threshold;

// Trigger 2: Time-based — only if conversation is active (message_count > 0).
let time_trigger = self.message_count > 0
&& elapsed.as_secs() >= wm_config.persistence_time_threshold_secs;

// Trigger 3: Event density — working memory events from this channel.
let density_trigger = if !message_trigger && !time_trigger {
// Only check DB if the cheap triggers didn't fire.
let since = chrono::Utc::now() - chrono::Duration::seconds(elapsed.as_secs() as i64);
match self
.deps
.working_memory
.count_events_since(self.id.as_ref(), since)
.await
{
Ok(count) => count as usize >= wm_config.persistence_event_density_threshold,
Err(error) => {
tracing::debug!(%error, "event density check failed, skipping");
false
}
let Some(kind) = memory_persistence_trigger_kind(
self.message_count,
elapsed_secs,
event_count_since_last,
&wm_config,
) else {
return;
};
kind
}
} else {
false
};

if !message_trigger && !time_trigger && !density_trigger {
return;
}

let trigger = if message_trigger {
"message_count"
} else if time_trigger {
"time"
} else {
"event_density"
};

// Reset counters before spawning so subsequent messages don't pile up.
Expand Down Expand Up @@ -3993,9 +4013,11 @@ mod tests {
ObserveModeFallbackState, branch_working_memory_event_summary,
classify_conversational_event_summary, compute_listen_mode_invocation, decision_user_id,
extract_decision_summary_from_reply, format_conversational_event_summary,
is_dm_conversation_id, recv_channel_event, should_process_event_for_channel,
should_send_discord_quiet_mode_ping_ack, should_send_quiet_mode_fallback,
is_dm_conversation_id, memory_persistence_trigger_kind, recv_channel_event,
should_process_event_for_channel, should_send_discord_quiet_mode_ping_ack,
should_send_quiet_mode_fallback,
};
use crate::config::WorkingMemoryConfig;
use crate::memory::{MemoryType, WorkingMemoryEventType};
use crate::{AgentId, ChannelId, InboundMessage, MessageContent, ProcessEvent, ProcessId};
use std::collections::HashMap;
Expand Down Expand Up @@ -4110,6 +4132,58 @@ mod tests {
);
}

#[test]
fn memory_persistence_trigger_prefers_message_count() {
let config = WorkingMemoryConfig {
persistence_message_threshold: 20,
persistence_time_threshold_secs: 900,
persistence_event_density_threshold: 5,
..WorkingMemoryConfig::default()
};

let trigger = memory_persistence_trigger_kind(20, 900, Some(10), &config);
assert_eq!(trigger, Some("message_count"));
}

#[test]
fn memory_persistence_trigger_uses_time_for_active_channels() {
let config = WorkingMemoryConfig {
persistence_message_threshold: 20,
persistence_time_threshold_secs: 900,
persistence_event_density_threshold: 5,
..WorkingMemoryConfig::default()
};

let trigger = memory_persistence_trigger_kind(3, 900, Some(10), &config);
assert_eq!(trigger, Some("time"));
}

#[test]
fn memory_persistence_trigger_uses_event_density_when_other_triggers_miss() {
let config = WorkingMemoryConfig {
persistence_message_threshold: 20,
persistence_time_threshold_secs: 900,
persistence_event_density_threshold: 5,
..WorkingMemoryConfig::default()
};

let trigger = memory_persistence_trigger_kind(3, 120, Some(5), &config);
assert_eq!(trigger, Some("event_density"));
}

#[test]
fn memory_persistence_trigger_requires_activity_for_time_fallback() {
let config = WorkingMemoryConfig {
persistence_message_threshold: 20,
persistence_time_threshold_secs: 900,
persistence_event_density_threshold: 5,
..WorkingMemoryConfig::default()
};

let trigger = memory_persistence_trigger_kind(0, 5000, Some(0), &config);
assert_eq!(trigger, None);
}

#[test]
fn decision_user_id_skips_retrigger_messages() {
let humans = vec![crate::config::HumanDef {
Expand Down
Loading
Loading