Skip to main content

agent_diva_core/memory/
provider.rs

1//! Provider boundary for Agent-Diva long-memory integration.
2//!
3//! The trait in this module is owned by `agent-diva-core` so Agent-Diva can
4//! depend on a stable domain contract without importing transport-specific
5//! CLI, MCP, or HTTP shapes. This matches the consuming-boundary pattern used
6//! by Laputa's adapter layer and keeps long-memory ownership outside prompt
7//! assembly and loop execution code.
8
9use std::path::PathBuf;
10
11/// Deterministic status for startup wakeup injection.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum StartupStatus {
14    /// Fresh startup content was produced for the current wakeup.
15    Ready,
16    /// Startup continuity could not be assembled. `last_usable_wakeup` stays
17    /// `None` when no cache is reused, which is the current default policy.
18    Degraded {
19        reason: String,
20        last_usable_wakeup: Option<SystemPromptBlock>,
21    },
22}
23
24/// Startup wakeup result consumed by prompt assembly.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct SystemPromptResponse {
27    pub status: StartupStatus,
28    pub prompt_block: Option<SystemPromptBlock>,
29}
30
31impl SystemPromptResponse {
32    #[must_use]
33    pub fn ready(prompt_block: SystemPromptBlock) -> Self {
34        Self {
35            status: StartupStatus::Ready,
36            prompt_block: Some(prompt_block),
37        }
38    }
39
40    #[must_use]
41    pub fn degraded(reason: impl Into<String>) -> Self {
42        let reason = reason.into();
43        Self {
44            prompt_block: Some(SystemPromptBlock {
45                shape: StartupInjectionShape::CompactRenderedMarkdown,
46                markdown: render_degraded_startup_markdown(&reason),
47            }),
48            status: StartupStatus::Degraded {
49                reason,
50                last_usable_wakeup: None,
51            },
52        }
53    }
54}
55
56/// Explicit shape chosen for startup injection into prompt assembly.
57///
58/// Agent-Diva currently consumes a compact rendered markdown block at the
59/// prompt seam instead of a transport envelope or raw backend payload.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum StartupInjectionShape {
62    /// Compact rendered markdown ready for direct prompt inclusion.
63    CompactRenderedMarkdown,
64}
65
66/// Deterministic status for intent-aware prefetch.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum PrefetchStatus {
69    /// No actionable intent was available, so recall was intentionally skipped.
70    SkippedNoIntent,
71    /// Recall completed and yielded a prompt block or an empty-but-successful result.
72    Ready,
73    /// Recall was attempted but failed.
74    Failed { reason: String },
75}
76
77/// Deterministic status for post-turn synchronization.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum SyncTurnStatus {
80    /// At least one durable write completed successfully.
81    Persisted,
82    /// No durable write was needed for this turn.
83    Noop,
84    /// A write was attempted but did not complete successfully.
85    Failed { reason: String },
86}
87
88/// Deterministic status for session-end shutdown handling.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum SessionEndStatus {
91    /// Shutdown hook ran and triggered work.
92    Triggered,
93    /// Shutdown hook intentionally performed no work.
94    Noop,
95    /// This session-end call was already handled and is idempotently ignored.
96    AlreadyHandled,
97    /// Shutdown work failed.
98    Failed { reason: String },
99}
100
101/// Minimal provider-facing summary of a wakeup-style state bundle.
102///
103/// This mirrors the durable, domain-oriented content Agent-Diva needs from a
104/// Laputa-style wakeup without depending on Laputa crate types directly.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct WakeupPackSummary {
107    pub identity: String,
108    pub recent_state: String,
109    pub latest_capsule: Option<String>,
110    pub key_relations: Vec<String>,
111    pub unresolved_threads: Vec<String>,
112    pub generated_at: Option<String>,
113}
114
115/// Provider-facing representation of a startup-relevant rhythm signal.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct RhythmTrigger {
118    pub name: String,
119    pub reason: Option<String>,
120}
121
122/// Structured startup support data that can be rendered into the chosen
123/// provider-consumable prompt block shape.
124#[derive(Debug, Clone, PartialEq, Eq, Default)]
125pub struct StartupContextSnapshot {
126    /// Optional `.laputa` state root associated with the current session.
127    pub laputa_state_root: Option<PathBuf>,
128    /// Optional rendered SOUL projection markdown.
129    pub soul_markdown: Option<String>,
130    /// Optional rendered WAKEUP projection markdown.
131    pub wakeup_markdown: Option<String>,
132    /// Optional structured wakeup summary when markdown projections are not
133    /// already available.
134    pub wakeup_pack: Option<WakeupPackSummary>,
135    /// Optional rhythm signals relevant at startup.
136    pub rhythm_triggers: Vec<RhythmTrigger>,
137    /// Optional fallback block from existing Agent-Diva core outputs.
138    pub memory_markdown: Option<String>,
139}
140
141impl StartupContextSnapshot {
142    /// Render structured startup data into the explicit prompt seam shape.
143    pub fn into_system_prompt_block(self) -> Option<SystemPromptBlock> {
144        let markdown = self.render_compact_markdown();
145        if markdown.is_empty() {
146            None
147        } else {
148            Some(SystemPromptBlock {
149                shape: StartupInjectionShape::CompactRenderedMarkdown,
150                markdown,
151            })
152        }
153    }
154
155    fn render_compact_markdown(&self) -> String {
156        let mut sections = Vec::new();
157
158        if let Some(memory_markdown) = trimmed_markdown(self.memory_markdown.as_deref()) {
159            sections.push(memory_markdown.to_string());
160        }
161
162        if let Some(soul_markdown) = trimmed_markdown(self.soul_markdown.as_deref()) {
163            sections.push(format!("## Soul Projection\n{}", soul_markdown));
164        }
165
166        if let Some(wakeup_markdown) = trimmed_markdown(self.wakeup_markdown.as_deref()) {
167            sections.push(format!("## Wakeup Projection\n{}", wakeup_markdown));
168        } else if let Some(wakeup_pack) = &self.wakeup_pack {
169            sections.push(render_wakeup_pack_summary(wakeup_pack));
170        }
171
172        if !self.rhythm_triggers.is_empty() {
173            let triggers = self
174                .rhythm_triggers
175                .iter()
176                .map(|trigger| match trigger.reason.as_deref() {
177                    Some(reason) if !reason.trim().is_empty() => {
178                        format!("- {} — {}", trigger.name.trim(), reason.trim())
179                    }
180                    _ => format!("- {}", trigger.name.trim()),
181                })
182                .collect::<Vec<_>>()
183                .join("\n");
184            sections.push(format!("## Rhythm Signals\n{}", triggers));
185        }
186
187        sections.join("\n\n")
188    }
189}
190
191fn render_degraded_startup_markdown(reason: &str) -> String {
192    format!(
193        "## Memory Startup Status\n- status: degraded\n- reason: {}\n- last_usable_wakeup: omitted (no cache reuse)\n",
194        reason.trim()
195    )
196}
197
198fn trimmed_markdown(markdown: Option<&str>) -> Option<&str> {
199    let markdown = markdown?.trim();
200    if markdown.is_empty() {
201        None
202    } else {
203        Some(markdown)
204    }
205}
206
207fn render_wakeup_pack_summary(pack: &WakeupPackSummary) -> String {
208    let latest_capsule = pack.latest_capsule.as_deref().unwrap_or("None");
209    let key_relations = if pack.key_relations.is_empty() {
210        "- None".to_string()
211    } else {
212        pack.key_relations
213            .iter()
214            .map(|item| format!("- {}", item.trim()))
215            .collect::<Vec<_>>()
216            .join("\n")
217    };
218    let unresolved_threads = if pack.unresolved_threads.is_empty() {
219        "- None".to_string()
220    } else {
221        pack.unresolved_threads
222            .iter()
223            .map(|item| format!("- {}", item.trim()))
224            .collect::<Vec<_>>()
225            .join("\n")
226    };
227
228    let mut rendered = String::from("## Wakeup Summary");
229    if let Some(generated_at) = trimmed_markdown(pack.generated_at.as_deref()) {
230        rendered.push_str("\nGenerated: ");
231        rendered.push_str(generated_at);
232    }
233    rendered.push_str("\n\n### Identity\n");
234    rendered.push_str(pack.identity.trim());
235    rendered.push_str("\n\n### Recent State\n");
236    rendered.push_str(pack.recent_state.trim());
237    rendered.push_str("\n\n### Latest Capsule\n");
238    rendered.push_str(latest_capsule.trim());
239    rendered.push_str("\n\n### Key Relations\n");
240    rendered.push_str(&key_relations);
241    rendered.push_str("\n\n### Unresolved Threads\n");
242    rendered.push_str(&unresolved_threads);
243    rendered
244}
245
246/// Input for startup wakeup-style prompt generation.
247///
248/// This request maps to the `turn_start` side of D-002 and the
249/// `laputa_wakeup` / `laputa_project_soul` style boundary described by D-010.
250/// Implementations should return only the markdown block Agent-Diva needs to
251/// splice into the system prompt, not transport envelopes or backend rows.
252#[derive(Debug, Clone, PartialEq, Eq)]
253pub struct SystemPromptRequest {
254    /// Workspace root for the active agent session.
255    pub workspace_root: PathBuf,
256}
257
258/// Startup block injected into the Agent-Diva system prompt.
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct SystemPromptBlock {
261    /// Explicitly chosen startup injection shape consumed by prompt assembly.
262    pub shape: StartupInjectionShape,
263    /// Markdown content suitable for direct prompt inclusion.
264    pub markdown: String,
265}
266
267/// Input for optional intent-aware recall during a live turn.
268///
269/// This represents the D-010 `laputa_recall_intent(intent, current_room)`
270/// shape at the Agent-Diva boundary. The contract stays domain-oriented: it
271/// carries the inferred intent and room context, not CLI flags or route names.
272#[derive(Debug, Clone, PartialEq, Eq)]
273pub struct PrefetchRequest {
274    /// Workspace root for the active agent session.
275    pub workspace_root: PathBuf,
276    /// Intent inferred from the current turn.
277    pub intent: String,
278    /// Optional current room or topic context.
279    pub current_room: Option<String>,
280    /// Optional user message or distilled query text.
281    pub user_message: Option<String>,
282}
283
284/// Optional memory material returned for mid-turn recall.
285#[derive(Debug, Clone, PartialEq, Eq)]
286pub struct PrefetchResponse {
287    /// Deterministic prefetch outcome for consumers.
288    pub status: PrefetchStatus,
289    /// Markdown block that can be injected into turn context when available.
290    pub prompt_block: Option<String>,
291}
292
293impl Default for PrefetchResponse {
294    fn default() -> Self {
295        Self {
296            status: PrefetchStatus::SkippedNoIntent,
297            prompt_block: None,
298        }
299    }
300}
301
302/// Input for post-successful-turn synchronization.
303///
304/// This is the Agent-Diva side of D-002 `sync_turn(events)` and checklist
305/// items 5-7. The first contract version is intentionally small: it can carry
306/// distilled memory updates and a persisted history/evidence entry without
307/// leaking backend-specific write commands.
308#[derive(Debug, Clone, PartialEq, Eq)]
309pub struct SyncTurnRequest {
310    /// Workspace root for the active agent session.
311    pub workspace_root: PathBuf,
312    /// Optional full replacement or refreshed long-memory markdown.
313    pub memory_update_markdown: Option<String>,
314    /// Optional history/evidence line derived from the completed turn.
315    pub history_entry: Option<String>,
316}
317
318/// Result of turn synchronization.
319#[derive(Debug, Clone, PartialEq, Eq)]
320pub struct SyncTurnResponse {
321    /// Deterministic sync outcome for consumers.
322    pub status: SyncTurnStatus,
323}
324
325impl Default for SyncTurnResponse {
326    fn default() -> Self {
327        Self {
328            status: SyncTurnStatus::Noop,
329        }
330    }
331}
332
333/// Input for session shutdown rhythm handling.
334///
335/// This maps to the `on_session_end()` hook requested by checklist item 5.6,
336/// which is intentionally broader than a specific scheduler or transport call.
337#[derive(Debug, Clone, PartialEq, Eq)]
338pub struct SessionEndRequest {
339    /// Workspace root for the active agent session.
340    pub workspace_root: PathBuf,
341    /// Optional Agent-Diva session identifier.
342    pub session_id: Option<String>,
343}
344
345/// Result of session shutdown handling.
346#[derive(Debug, Clone, PartialEq, Eq)]
347pub struct SessionEndResponse {
348    /// Deterministic shutdown outcome for consumers.
349    pub status: SessionEndStatus,
350}
351
352impl Default for SessionEndResponse {
353    fn default() -> Self {
354        Self {
355            status: SessionEndStatus::Noop,
356        }
357    }
358}
359
360/// Isolation layer between Agent-Diva and any long-memory backend.
361///
362/// Contract rules:
363/// - `system_prompt_block()` is synchronous because prompt assembly in
364///   `ContextBuilder::build_system_prompt()` is synchronous today.
365/// - `prefetch()`, `sync_turn()`, and `on_session_end()` are async because
366///   live-turn recall, post-turn persistence, and shutdown rhythm work may
367///   require I/O and already sit on async paths in Agent-Diva.
368/// - All request/response types are Agent-Diva-owned domain structs; do not
369///   leak MCP schemas, CLI arguments, HTTP routes, or backend model types.
370#[async_trait::async_trait]
371pub trait MemoryProvider: Send + Sync {
372    /// Build the startup memory block for system prompt assembly.
373    fn system_prompt_block(
374        &self,
375        request: &SystemPromptRequest,
376    ) -> crate::Result<SystemPromptResponse>;
377
378    /// Perform optional intent-aware prefetch for a live turn.
379    async fn prefetch(&self, request: PrefetchRequest) -> crate::Result<PrefetchResponse>;
380
381    /// Persist evidence after a successful turn completes.
382    async fn sync_turn(&self, request: SyncTurnRequest) -> crate::Result<SyncTurnResponse>;
383
384    /// Trigger shutdown/session-end rhythm work if needed.
385    async fn on_session_end(
386        &self,
387        request: SessionEndRequest,
388    ) -> crate::Result<SessionEndResponse>;
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    struct DummyProvider;
396
397    #[async_trait::async_trait]
398    impl MemoryProvider for DummyProvider {
399        fn system_prompt_block(
400            &self,
401            request: &SystemPromptRequest,
402        ) -> crate::Result<SystemPromptResponse> {
403            Ok(SystemPromptResponse::ready(SystemPromptBlock {
404                shape: StartupInjectionShape::CompactRenderedMarkdown,
405                markdown: format!("workspace={}", request.workspace_root.display()),
406            }))
407        }
408
409        async fn prefetch(&self, request: PrefetchRequest) -> crate::Result<PrefetchResponse> {
410            Ok(PrefetchResponse {
411                status: PrefetchStatus::Ready,
412                prompt_block: Some(format!(
413                    "intent={} room={}",
414                    request.intent,
415                    request.current_room.unwrap_or_else(|| "none".to_string())
416                )),
417            })
418        }
419
420        async fn sync_turn(&self, request: SyncTurnRequest) -> crate::Result<SyncTurnResponse> {
421            Ok(SyncTurnResponse {
422                status: if request.memory_update_markdown.is_some() || request.history_entry.is_some()
423                {
424                    SyncTurnStatus::Persisted
425                } else {
426                    SyncTurnStatus::Noop
427                },
428            })
429        }
430
431        async fn on_session_end(
432            &self,
433            request: SessionEndRequest,
434        ) -> crate::Result<SessionEndResponse> {
435            Ok(SessionEndResponse {
436                status: if request.session_id.is_some() {
437                    SessionEndStatus::Triggered
438                } else {
439                    SessionEndStatus::Noop
440                },
441            })
442        }
443    }
444
445    #[tokio::test]
446    async fn test_memory_provider_contract_is_domain_only() {
447        let provider = DummyProvider;
448        let prompt = provider
449            .system_prompt_block(&SystemPromptRequest {
450                workspace_root: PathBuf::from("/tmp/diva"),
451            })
452            .unwrap()
453            .prompt_block
454            .expect("dummy provider should return a prompt block");
455        assert!(prompt.markdown.contains("workspace=/tmp/diva"));
456        assert_eq!(prompt.shape, StartupInjectionShape::CompactRenderedMarkdown);
457
458        let prefetch = provider
459            .prefetch(PrefetchRequest {
460                workspace_root: PathBuf::from("/tmp/diva"),
461                intent: "recall-project-status".to_string(),
462                current_room: Some("roadmap".to_string()),
463                user_message: Some("what changed?".to_string()),
464        })
465        .await
466        .unwrap();
467        assert_eq!(prefetch.status, PrefetchStatus::Ready);
468        assert_eq!(prefetch.prompt_block.as_deref(), Some("intent=recall-project-status room=roadmap"));
469
470        let sync = provider
471            .sync_turn(SyncTurnRequest {
472                workspace_root: PathBuf::from("/tmp/diva"),
473                memory_update_markdown: Some("updated".to_string()),
474                history_entry: None,
475        })
476        .await
477        .unwrap();
478        assert_eq!(sync.status, SyncTurnStatus::Persisted);
479
480        let shutdown = provider
481            .on_session_end(SessionEndRequest {
482                workspace_root: PathBuf::from("/tmp/diva"),
483                session_id: Some("session-42".to_string()),
484        })
485        .await
486        .unwrap();
487        assert_eq!(shutdown.status, SessionEndStatus::Triggered);
488    }
489
490    #[test]
491    fn test_startup_context_snapshot_renders_compact_markdown() {
492        let block = StartupContextSnapshot {
493            laputa_state_root: Some(PathBuf::from("/tmp/diva/.laputa")),
494            soul_markdown: Some("# Identity\n\nGenerated soul".to_string()),
495            wakeup_markdown: None,
496            wakeup_pack: Some(WakeupPackSummary {
497                identity: "You are Diva.".to_string(),
498                recent_state: "- roadmap: Hot (heat: 5)".to_string(),
499                latest_capsule: Some("Weekly review complete.".to_string()),
500                key_relations: vec!["maintainer <-> roadmap".to_string()],
501                unresolved_threads: vec!["ship provider boundary".to_string()],
502                generated_at: Some("2026-05-08 10:00 UTC".to_string()),
503            }),
504            rhythm_triggers: vec![RhythmTrigger {
505                name: "weekly".to_string(),
506                reason: Some("capsule due".to_string()),
507            }],
508            memory_markdown: Some("## Long-term Memory\nExisting durable memory".to_string()),
509        }
510        .into_system_prompt_block()
511        .expect("startup context should render a prompt block");
512
513        assert_eq!(block.shape, StartupInjectionShape::CompactRenderedMarkdown);
514        assert!(block.markdown.contains("## Long-term Memory"));
515        assert!(block.markdown.contains("## Soul Projection"));
516        assert!(block.markdown.contains("## Wakeup Summary"));
517        assert!(block.markdown.contains("## Rhythm Signals"));
518        assert!(block.markdown.contains("weekly — capsule due"));
519    }
520
521    #[test]
522    fn test_degraded_startup_explicitly_omits_cached_wakeup() {
523        let response = SystemPromptResponse::degraded("wakeup generation failed");
524
525        match response.status {
526            StartupStatus::Degraded {
527                reason,
528                last_usable_wakeup,
529            } => {
530                assert_eq!(reason, "wakeup generation failed");
531                assert!(last_usable_wakeup.is_none());
532            }
533            other => panic!("expected degraded startup, got {other:?}"),
534        }
535
536        let block = response
537            .prompt_block
538            .expect("degraded startup should still emit an explicit prompt block");
539        assert!(block.markdown.contains("status: degraded"));
540        assert!(block.markdown.contains("last_usable_wakeup: omitted"));
541    }
542}