Skip to content

Commit 0af6765

Browse files
committed
feat(memory): expand working-memory event semantics
1 parent df4ac10 commit 0af6765

11 files changed

Lines changed: 868 additions & 135 deletions

docs/design-docs/working-memory-implementation-plan.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,12 @@ pub enum WorkingMemoryEventType {
158158
CronExecuted,
159159
MemorySaved,
160160
Decision,
161+
UserCorrection,
162+
DecisionRevised,
163+
DeadlineSet,
164+
BlockedOn,
165+
Constraint,
166+
Outcome,
161167
Error,
162168
TaskUpdate,
163169
AgentMessage,
@@ -861,7 +867,8 @@ Update `prompts/en/memory_persistence.md.j2` to add:
861867
In addition to saving graph memories, identify key decisions and important events
862868
from the conversation. For each, include it in the `events` field of
863869
memory_persistence_complete. Events should be one-line summaries with a type
864-
("decision", "error", "system") and importance score (0.0-1.0).
870+
("decision", "user_correction", "decision_revised", "deadline_set", "blocked_on",
871+
"constraint", "outcome", "error", "system") and importance score (0.0-1.0).
865872
```
866873

867874
Update `src/tools/memory_persistence_complete.rs`:

docs/design-docs/working-memory.md

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,18 @@ pub enum WorkingMemoryEventType {
7575
MemorySaved,
7676
/// A decision was made (extracted from conversation)
7777
Decision,
78+
/// The user corrected a prior assumption, instruction, or framing
79+
UserCorrection,
80+
/// A prior decision changed after feedback or new information
81+
DecisionRevised,
82+
/// A concrete due date, deadline, or scheduled milestone was set
83+
DeadlineSet,
84+
/// Progress is waiting on approval, missing input, or an external dependency
85+
BlockedOn,
86+
/// A hard requirement, limitation, or non-negotiable boundary was stated
87+
Constraint,
88+
/// A task, branch, or delegated step reached a clear terminal result
89+
Outcome,
7890
/// An error or failure occurred
7991
Error,
8092
/// A task was created or updated
@@ -455,19 +467,19 @@ The periodic memory persistence branch currently runs every 50 user messages. Re
455467

456468
### Persistence Branch Dual Output
457469

458-
The memory persistence branch gains a second responsibility: in addition to saving graph memories, it emits `Decision` and `MemorySaved` events into the working memory log. This connects the two systems:
470+
The memory persistence branch gains a second responsibility: in addition to saving graph memories, it emits typed conversational events into the working memory log. This connects the two systems:
459471

460472
```
461473
Persistence branch runs:
462474
1. Recalls existing graph memories (avoid duplicates)
463475
2. Reads conversation history since last run
464476
3. Saves new graph memories via memory_save (as today)
465477
4. Identifies key decisions and events
466-
5. Emits working memory events for each decision identified
478+
5. Emits working memory events for each important event identified
467479
6. Calls memory_persistence_complete
468480
```
469481

470-
Step 5 is new. The persistence branch prompt is updated to instruct it to identify decisions explicitly. The `memory_persistence_complete` tool gains an optional `events` field:
482+
Step 5 is new. The persistence branch prompt is updated to identify durable temporal events explicitly: decisions, user corrections, revised decisions, concrete deadlines, blockers, constraints, terminal outcomes, errors, and system events. The `memory_persistence_complete` tool gains an optional `events` field:
471483

472484
```rust
473485
pub struct MemoryPersistenceCompleteArgs {
@@ -477,7 +489,7 @@ pub struct MemoryPersistenceCompleteArgs {
477489
}
478490

479491
pub struct WorkingMemoryEventInput {
480-
pub event_type: String, // "decision", "error", etc.
492+
pub event_type: String, // "decision", "blocked_on", "outcome", etc.
481493
pub summary: String,
482494
pub importance: Option<f32>,
483495
}
@@ -490,14 +502,18 @@ The majority of working memory events are captured programmatically, with zero L
490502
| Event | Emitter | How |
491503
| ---------------- | -------------------------------------------- | -------------------------------------------------------------------- |
492504
| Worker spawned | `spawn_worker` tool handler | After successful spawn, write event with task description as summary |
493-
| Worker completed | Worker state machine terminal transition | Write event with worker result summary (truncated to 200 chars) |
494-
| Branch completed | Branch return path in channel | Write event with branch conclusion (truncated to 200 chars) |
505+
| Worker completed | Worker state machine terminal transition | Write typed result, blocker, constraint, or deadline event when the summary uses a recognized prefix; otherwise write `WorkerCompleted` |
506+
| Branch completed | Branch return path in channel | Write typed result, blocker, constraint, or deadline event when the summary uses a recognized prefix; otherwise write `BranchCompleted` |
495507
| Cron executed | Cron scheduler after job completes | Write event with cron name + outcome |
496508
| Memory saved | `memory_save` tool handler | Write event with memory type + content preview |
497-
| Task updated | Task tool handlers | Write event with task title + new status |
509+
| Task updated | Task tool handlers | Write `Outcome` when a task transitions to `done`; otherwise write `TaskUpdate` with task status |
510+
| Duplicate worker | `spawn_worker` duplicate guard | Write `BlockedOn` with the active worker ID and duplicate task preview |
511+
| Agent delegation | `send_agent_message` tool handler | Write `Outcome` when delegation creates the cross-agent task |
498512
| Error | SpacebotHook on tool failure, worker failure | Write event with error description |
499513
| System | Startup, config change, maintenance | Write event with description |
500514

515+
Branch and worker summaries may opt into richer event types with these prefixes: `outcome:`, `blocked_on:` or `blocked on:`, `constraint:`, and `deadline_set:` or `deadline:`. The prefix is stripped before rendering the event summary.
516+
501517
Each emitter calls `working_memory_store.record_event()` as a fire-and-forget `tokio::spawn`. The message processing pipeline never waits on event recording.
502518

503519
```rust

prompts/en/memory_persistence.md.j2

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,17 @@ This is an automatic process triggered periodically during conversation. You are
3232
- `"decision"` for commitments or choices made
3333
- `"user_correction"` when the user corrects a prior assumption, instruction, or framing
3434
- `"decision_revised"` when a prior choice changes after feedback or new information
35+
- `"deadline_set"` when the conversation establishes a concrete due date, deadline, or scheduled milestone
36+
- `"blocked_on"` when progress is waiting on an approval, dependency, missing input, or external action
37+
- `"constraint"` when the user or system states a hard requirement, limitation, or non-negotiable boundary
38+
- `"outcome"` when a task, branch, or delegated step reaches a clear terminal result worth retaining in temporal memory
3539
- `"error"` for failures or problems
3640
- `"system"` for other notable events
3741
- `summary`: one-line description of what happened
3842
- Normalize relative time references to absolute dates/times with timezone
3943
(for example `2026-03-31T14:20:00-04:00`) so downstream memory checks are
4044
stable across sessions.
41-
- `importance`: 0.0-1.0 score (`decision`, `user_correction`, and `decision_revised` are typically 0.6-0.8)
45+
- `importance`: 0.0-1.0 score (`decision`, `user_correction`, `decision_revised`, `blocked_on`, and `outcome` are typically 0.6-0.8)
4246
- Events feed the agent's temporal working memory — they help the agent remember *what happened today*, not just facts.
4347

4448
5. **Finish with the terminal tool.** You must call `memory_persistence_complete` before finishing:

src/agent/channel.rs

Lines changed: 174 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,70 @@ fn should_flush_coalesce_buffer_for_event(event: &ProcessEvent) -> bool {
119119
)
120120
}
121121

122+
fn classify_conversational_event_summary(
123+
summary: &str,
124+
default_event_type: crate::memory::WorkingMemoryEventType,
125+
) -> (crate::memory::WorkingMemoryEventType, String) {
126+
let trimmed = summary.trim();
127+
if trimmed.is_empty() {
128+
return (default_event_type, String::new());
129+
}
130+
131+
if let Some((prefix, rest)) = trimmed.split_once(':') {
132+
let rest_trimmed = rest.trim();
133+
let prefix = prefix.trim().to_ascii_lowercase().replace([' ', '-'], "_");
134+
if prefix == "outcome" {
135+
return (
136+
crate::memory::WorkingMemoryEventType::Outcome,
137+
rest_trimmed.to_string(),
138+
);
139+
}
140+
if prefix == "blocked_on" {
141+
return (
142+
crate::memory::WorkingMemoryEventType::BlockedOn,
143+
rest_trimmed.to_string(),
144+
);
145+
}
146+
if prefix == "constraint" {
147+
return (
148+
crate::memory::WorkingMemoryEventType::Constraint,
149+
rest_trimmed.to_string(),
150+
);
151+
}
152+
if prefix == "deadline_set" || prefix == "deadline" {
153+
return (
154+
crate::memory::WorkingMemoryEventType::DeadlineSet,
155+
rest_trimmed.to_string(),
156+
);
157+
}
158+
}
159+
160+
(default_event_type, trimmed.to_string())
161+
}
162+
163+
fn format_conversational_event_summary(
164+
event_type: crate::memory::WorkingMemoryEventType,
165+
source: &str,
166+
event_summary: &str,
167+
) -> String {
168+
let label = match event_type {
169+
crate::memory::WorkingMemoryEventType::Outcome => "outcome",
170+
crate::memory::WorkingMemoryEventType::BlockedOn => "blocked on",
171+
crate::memory::WorkingMemoryEventType::Constraint => "constraint",
172+
crate::memory::WorkingMemoryEventType::DeadlineSet => "deadline set",
173+
crate::memory::WorkingMemoryEventType::Error => "failed",
174+
crate::memory::WorkingMemoryEventType::BranchCompleted
175+
| crate::memory::WorkingMemoryEventType::WorkerCompleted => "completed",
176+
_ => "concluded",
177+
};
178+
179+
if event_summary.is_empty() {
180+
format!("{source} {label}")
181+
} else {
182+
format!("{source} {label}: {event_summary}")
183+
}
184+
}
185+
122186
fn sentence_contains_decision_marker(sentence: &str) -> bool {
123187
let sentence_lower = sentence.to_ascii_lowercase();
124188
DECISION_MARKERS
@@ -3224,11 +3288,19 @@ impl Channel {
32243288
} else {
32253289
conclusion.clone()
32263290
};
3291+
let (event_type, event_summary) = classify_conversational_event_summary(
3292+
&summary,
3293+
crate::memory::WorkingMemoryEventType::BranchCompleted,
3294+
);
32273295
self.deps
32283296
.working_memory
32293297
.emit(
3230-
crate::memory::WorkingMemoryEventType::BranchCompleted,
3231-
format!("Branch concluded: {summary}"),
3298+
event_type,
3299+
format_conversational_event_summary(
3300+
event_type,
3301+
"Branch",
3302+
&event_summary,
3303+
),
32323304
)
32333305
.channel(self.id.to_string())
32343306
.importance(0.7)
@@ -3297,20 +3369,18 @@ impl Channel {
32973369
} else {
32983370
result.clone()
32993371
};
3300-
let event_type = if *success {
3372+
let default_event_type = if *success {
33013373
crate::memory::WorkingMemoryEventType::WorkerCompleted
33023374
} else {
33033375
crate::memory::WorkingMemoryEventType::Error
33043376
};
3377+
let (event_type, event_summary) =
3378+
classify_conversational_event_summary(&worker_summary, default_event_type);
33053379
self.deps
33063380
.working_memory
33073381
.emit(
33083382
event_type,
3309-
if *success {
3310-
format!("Worker completed: {worker_summary}")
3311-
} else {
3312-
format!("Worker failed: {worker_summary}")
3313-
},
3383+
format_conversational_event_summary(event_type, "Worker", &event_summary),
33143384
)
33153385
.channel(self.id.to_string())
33163386
.importance(if *success { 0.6 } else { 0.8 })
@@ -3890,12 +3960,13 @@ fn is_dm_conversation_id(conv_id: &str) -> bool {
38903960
#[cfg(test)]
38913961
mod tests {
38923962
use super::{
3893-
ObserveModeFallbackState, compute_listen_mode_invocation, decision_user_id,
3894-
extract_decision_summary_from_reply, is_dm_conversation_id, recv_channel_event,
3963+
ObserveModeFallbackState, classify_conversational_event_summary,
3964+
compute_listen_mode_invocation, decision_user_id, extract_decision_summary_from_reply,
3965+
format_conversational_event_summary, is_dm_conversation_id, recv_channel_event,
38953966
should_process_event_for_channel, should_send_discord_quiet_mode_ping_ack,
38963967
should_send_quiet_mode_fallback,
38973968
};
3898-
use crate::memory::MemoryType;
3969+
use crate::memory::{MemoryType, WorkingMemoryEventType};
38993970
use crate::{AgentId, ChannelId, InboundMessage, MessageContent, ProcessEvent, ProcessId};
39003971
use std::collections::HashMap;
39013972
use std::sync::Arc;
@@ -4136,6 +4207,98 @@ mod tests {
41364207
assert!(!should_process_event_for_channel(&event, &channel_id));
41374208
}
41384209

4210+
#[test]
4211+
fn conversational_event_summary_extracts_outcome_prefix() {
4212+
let (event_type, summary) = classify_conversational_event_summary(
4213+
"outcome: implemented the migration safety check",
4214+
WorkingMemoryEventType::WorkerCompleted,
4215+
);
4216+
assert_eq!(event_type, WorkingMemoryEventType::Outcome);
4217+
assert_eq!(summary, "implemented the migration safety check");
4218+
}
4219+
4220+
#[test]
4221+
fn conversational_event_summary_extracts_blocked_on_prefix() {
4222+
let (event_type, summary) = classify_conversational_event_summary(
4223+
"blocked_on: waiting for review from infra",
4224+
WorkingMemoryEventType::Error,
4225+
);
4226+
assert_eq!(event_type, WorkingMemoryEventType::BlockedOn);
4227+
assert_eq!(summary, "waiting for review from infra");
4228+
}
4229+
4230+
#[test]
4231+
fn conversational_event_summary_falls_back_to_default_type() {
4232+
let (event_type, summary) = classify_conversational_event_summary(
4233+
"completed with no blockers",
4234+
WorkingMemoryEventType::WorkerCompleted,
4235+
);
4236+
assert_eq!(event_type, WorkingMemoryEventType::WorkerCompleted);
4237+
assert_eq!(summary, "completed with no blockers");
4238+
}
4239+
4240+
#[test]
4241+
fn conversational_event_summary_extracts_constraint_prefix_case_insensitively() {
4242+
let (event_type, summary) = classify_conversational_event_summary(
4243+
"CoNsTrAiNt: must keep migrations immutable",
4244+
WorkingMemoryEventType::WorkerCompleted,
4245+
);
4246+
assert_eq!(event_type, WorkingMemoryEventType::Constraint);
4247+
assert_eq!(summary, "must keep migrations immutable");
4248+
}
4249+
4250+
#[test]
4251+
fn conversational_event_summary_is_case_insensitive_across_prefixes() {
4252+
let (event_type, summary) = classify_conversational_event_summary(
4253+
"OUTCOME: implemented the follow-up",
4254+
WorkingMemoryEventType::WorkerCompleted,
4255+
);
4256+
assert_eq!(event_type, WorkingMemoryEventType::Outcome);
4257+
assert_eq!(summary, "implemented the follow-up");
4258+
4259+
let (event_type, summary) = classify_conversational_event_summary(
4260+
"Blocked_On: waiting on reviewer signoff",
4261+
WorkingMemoryEventType::WorkerCompleted,
4262+
);
4263+
assert_eq!(event_type, WorkingMemoryEventType::BlockedOn);
4264+
assert_eq!(summary, "waiting on reviewer signoff");
4265+
4266+
let (event_type, summary) = classify_conversational_event_summary(
4267+
"blocked on: user approval",
4268+
WorkingMemoryEventType::WorkerCompleted,
4269+
);
4270+
assert_eq!(event_type, WorkingMemoryEventType::BlockedOn);
4271+
assert_eq!(summary, "user approval");
4272+
}
4273+
4274+
#[test]
4275+
fn conversational_event_summary_treats_empty_prefixed_content_as_empty_summary() {
4276+
let (event_type, summary) = classify_conversational_event_summary(
4277+
"outcome: ",
4278+
WorkingMemoryEventType::WorkerCompleted,
4279+
);
4280+
assert_eq!(event_type, WorkingMemoryEventType::Outcome);
4281+
assert!(summary.is_empty());
4282+
assert_eq!(
4283+
format_conversational_event_summary(event_type, "Worker", &summary),
4284+
"Worker outcome"
4285+
);
4286+
}
4287+
4288+
#[test]
4289+
fn conversational_event_summary_extracts_deadline_prefix() {
4290+
let (event_type, summary) = classify_conversational_event_summary(
4291+
"deadline-set: ship by 2026-04-20",
4292+
WorkingMemoryEventType::BranchCompleted,
4293+
);
4294+
assert_eq!(event_type, WorkingMemoryEventType::DeadlineSet);
4295+
assert_eq!(summary, "ship by 2026-04-20");
4296+
assert_eq!(
4297+
format_conversational_event_summary(event_type, "Branch", &summary),
4298+
"Branch deadline set: ship by 2026-04-20"
4299+
);
4300+
}
4301+
41394302
#[test]
41404303
fn quiet_mode_invocation_uses_discord_mention_and_reply_metadata() {
41414304
let message = inbound_message(

0 commit comments

Comments
 (0)