From 767be9751dc92baa7ce12be7c397d02476378e75 Mon Sep 17 00:00:00 2001 From: local Date: Tue, 2 Jun 2026 08:41:02 +0800 Subject: [PATCH 1/2] Add agent-owned Dreams memory policy Co-authored-by: multica-agent --- docs/architecture.md | 9 + .../.openspec.yaml | 2 + .../design.md | 75 +++++++++ .../proposal.md | 40 +++++ .../specs/agent-owned-dreams-memory/spec.md | 77 +++++++++ .../specs/codex-style-memory-system/spec.md | 17 ++ .../tasks.md | 15 ++ .../specs/agent-owned-dreams-memory/spec.md | 81 +++++++++ .../specs/codex-style-memory-system/spec.md | 16 ++ spec/ra-config.schema.json | 7 + spec/ra.toml.example | 1 + src/config.rs | 11 ++ src/memory.rs | 1 + src/memory_entry.rs | 155 ++++++++++++++++++ tests/memory_entry.rs | 114 ++++++++++++- tests/memory_system.rs | 2 + 16 files changed, 620 insertions(+), 3 deletions(-) create mode 100644 openspec/changes/archive/2026-06-02-add-agent-owned-dreams-memory/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-02-add-agent-owned-dreams-memory/design.md create mode 100644 openspec/changes/archive/2026-06-02-add-agent-owned-dreams-memory/proposal.md create mode 100644 openspec/changes/archive/2026-06-02-add-agent-owned-dreams-memory/specs/agent-owned-dreams-memory/spec.md create mode 100644 openspec/changes/archive/2026-06-02-add-agent-owned-dreams-memory/specs/codex-style-memory-system/spec.md create mode 100644 openspec/changes/archive/2026-06-02-add-agent-owned-dreams-memory/tasks.md create mode 100644 openspec/specs/agent-owned-dreams-memory/spec.md diff --git a/docs/architecture.md b/docs/architecture.md index decb175..dbb032a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -188,6 +188,15 @@ built-in tool. Graphify's NetworkX node-link JSON directly for native query/path/explain tools. The default update path is AST-only and local; semantic extraction is explicit because it may use an LLM backend. +- **Memory Dreams (`src/memory_entry.rs`)** — Claude-style Dreams are + represented as an agent-owned synthesis seam above the local memory + pipeline, not as a `MemoryEntryLifecycle` state. `DreamScheduler` + exposes explicit policy decisions for when an agent should start a dream, + which prior sessions are eligible inputs, and whether a completed output + store may be adopted through the same `decide_use` gate as generated + durable memories. The current layer intentionally does not call a live + Dreams API. Dream output is generated memory state; the original sessions + and input memory store remain source evidence. - **Persistence (`src/store.rs`, `src/atif_codec.rs`, `src/atif.rs`)** — the message log encodes to ATIF v1.7 JSONL under `RA_HOME/sessions//`. `atif_codec` must round-trip; diff --git a/openspec/changes/archive/2026-06-02-add-agent-owned-dreams-memory/.openspec.yaml b/openspec/changes/archive/2026-06-02-add-agent-owned-dreams-memory/.openspec.yaml new file mode 100644 index 0000000..db47328 --- /dev/null +++ b/openspec/changes/archive/2026-06-02-add-agent-owned-dreams-memory/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-02 diff --git a/openspec/changes/archive/2026-06-02-add-agent-owned-dreams-memory/design.md b/openspec/changes/archive/2026-06-02-add-agent-owned-dreams-memory/design.md new file mode 100644 index 0000000..b5974f4 --- /dev/null +++ b/openspec/changes/archive/2026-06-02-add-agent-owned-dreams-memory/design.md @@ -0,0 +1,75 @@ +## Context + +Ra's memory system currently has two layers: `memory_entry.rs` is a pure policy +model for per-entry lifecycle decisions, and `memory.rs` owns local generated +memory artifacts. WS-159 concluded that Claude-style Dreams should be a +follow-on synthesis pass above that pipeline. The agent, not the platform, +decides when to dream, owns the job state, decides whether to adopt the output, +and chooses which memory store to attach to future sessions. + +## Goals / Non-Goals + +**Goals:** + +- Add a pure local policy/state seam for agent-owned Dreams. +- Keep Dreams out of `MemoryEntryLifecycle`; that enum remains per-entry. +- Reuse `MemoryPolicy` and `decide_use` for output adoption. +- Add focused tests for scheduling decisions, input filtering, job adoption, and + config parsing/defaults. + +**Non-Goals:** + +- No live Anthropic Dreams API client call in this change. +- No persistence format for remote dream job records beyond the local state type. +- No automatic scheduler that starts jobs without an agent decision. + +## Decisions + +1. Add Dreams to `memory_entry.rs` as pure policy/state types. + + Rationale: the existing module is already the pure lifecycle contract future + integrations compose. A Dreams layer needs the same properties: no I/O, no + client calls, and tests that exercise deterministic policy decisions. + + Alternative considered: a new runtime module that spawns background jobs. That + would force API and persistence concerns into this first layer and obscure the + agent-owned decision boundary. + +2. Model scheduling as `ShouldDreamDecision` plus `DreamSkipReason`. + + Rationale: WS-160 requires explicit skip reasons for disabled memories, region + unavailability, low rate-limit headroom, and insufficient sessions. A boolean + API would hide actionable policy state from the agent. + +3. Model dream inputs as selected `MemoryCandidate` values. + + Rationale: PR #35 already uses `MemoryCandidate` for session-level + eligibility. Reusing it keeps active-session and minimum-duration semantics + consistent with generation decisions while allowing Dreams to ignore idle + delay because they consume prior sessions. + +4. Gate adoption through `decide_use`. + + Rationale: dream output is generated memory state. It should not bypass the + same global, region, thread-use, external-context, and durability checks used + for ordinary generated memory entries. + +## Risks / Trade-offs + +- The first layer does not call the Claude Dreams API -> Mitigation: expose job + state and input/output store seams explicitly so a later integration can plug + in a client boundary without changing policy tests. +- Reusing `MemoryCandidate` means the selected inputs do not yet carry remote + session ids -> Mitigation: this layer validates policy/filtering only; the + runtime that owns remote session ids can retain the ids alongside candidates. + +## Migration Plan + +Additive only. Existing configs continue to parse because the new +`min_sessions_between_dreams` field has a default. Regenerate the config schema +and update the example config. + +## Open Questions + +- Which Anthropic model/client boundary should own the live `POST /v1/dreams` + call remains for a later integration change. diff --git a/openspec/changes/archive/2026-06-02-add-agent-owned-dreams-memory/proposal.md b/openspec/changes/archive/2026-06-02-add-agent-owned-dreams-memory/proposal.md new file mode 100644 index 0000000..e397bda --- /dev/null +++ b/openspec/changes/archive/2026-06-02-add-agent-owned-dreams-memory/proposal.md @@ -0,0 +1,40 @@ +## Why + +Ra now has a Codex-style local memory pipeline, but it lacks a policy surface for +an agent to explicitly decide when to consolidate many sessions into a cleaner +memory store. Claude-style Dreams should sit above the per-entry lifecycle as an +agent-owned background synthesis job, not as another `MemoryEntryLifecycle` +state. + +## What Changes + +- Add a pure Dreams policy/state layer for agent-owned scheduling decisions. +- Represent dream jobs with explicit pending, running, completed, failed, and + canceled states, including input and optional output memory store ids. +- Add input selection that filters active and too-short sessions and caps inputs + at the Claude Dreams API limit of 100 sessions. +- Gate adoption of a completed dream output through the existing memory use + policy instead of bypassing `decide_use`. +- Add memory config support for `min_sessions_between_dreams`, defaulting to 10. +- Document that dream output is generated memory state while source sessions and + the input store remain source evidence. + +## Capabilities + +### New Capabilities + +- `agent-owned-dreams-memory`: Agent-owned Dreams scheduling, job state, input + selection, and output adoption policy for Ra memory synthesis. + +### Modified Capabilities + +- `codex-style-memory-system`: Add the memory configuration threshold that + controls the minimum number of sessions between Dreams. + +## Impact + +- Affected code: `src/memory_entry.rs`, `src/memory.rs`, `src/config.rs`, + `src/lib.rs`, config schema/example, and memory tests. +- No live Anthropic Dreams API integration is included in this layer; the new + API is the local policy/state seam that a later client integration can call. +- No new external dependencies. diff --git a/openspec/changes/archive/2026-06-02-add-agent-owned-dreams-memory/specs/agent-owned-dreams-memory/spec.md b/openspec/changes/archive/2026-06-02-add-agent-owned-dreams-memory/specs/agent-owned-dreams-memory/spec.md new file mode 100644 index 0000000..33ea1fb --- /dev/null +++ b/openspec/changes/archive/2026-06-02-add-agent-owned-dreams-memory/specs/agent-owned-dreams-memory/spec.md @@ -0,0 +1,77 @@ +## ADDED Requirements + +### Requirement: Agent-Owned Dream Scheduling + +Ra SHALL expose a pure agent-side Dreams scheduling decision that returns an +explicit decision object instead of silently skipping work. + +#### Scenario: Dream allowed + +- **WHEN** memories are enabled, the region is available, rate-limit headroom is + not below policy, and enough sessions have occurred since the last dream +- **THEN** the scheduling decision allows the agent to start a dream + +#### Scenario: Dream skipped with reason + +- **WHEN** memories are disabled, the region is unavailable, rate-limit headroom + is too low, or too few sessions have occurred since the last dream +- **THEN** the scheduling decision skips the dream and reports the matching + skip reason + +### Requirement: Dream Input Selection + +Ra SHALL allow the agent to select dream input sessions from session candidates +while excluding ineligible sessions and enforcing the Claude Dreams API input +limit. + +#### Scenario: Eligible dream inputs + +- **WHEN** candidate sessions are inactive and meet the configured minimum + session duration +- **THEN** Ra selects them as dream inputs + +#### Scenario: Ineligible and excess dream inputs + +- **WHEN** candidate sessions are active, shorter than the configured minimum + session duration, or exceed the 100-session input cap +- **THEN** Ra excludes active and too-short sessions and returns at most 100 + inputs + +### Requirement: Dream Job State + +Ra SHALL represent agent-owned dream jobs as state that tracks pending, running, +completed, failed, and canceled statuses, the input memory store id, and an +optional output memory store id. + +#### Scenario: Dream job tracks output store + +- **WHEN** a dream job is completed with an output memory store id +- **THEN** Ra preserves both the original input store id and the completed output + store id in the job state + +### Requirement: Dream Output Adoption Gate + +Ra SHALL require completed dream outputs to pass through the existing memory use +policy before they can become active for a future session. + +#### Scenario: Completed output is considered for use + +- **WHEN** a dream job is completed and has an output memory store id +- **THEN** Ra evaluates adoption with the same memory use gate used for generated + durable memory entries + +#### Scenario: Non-completed output is suppressed + +- **WHEN** a dream job is pending, running, failed, or canceled +- **THEN** Ra suppresses adoption instead of treating the output as active + +### Requirement: Dream Evidence Boundary + +Ra SHALL document that dream output is generated memory state while original +sessions and the input memory store remain source evidence. + +#### Scenario: Developer guidance distinguishes generated state and evidence + +- **WHEN** developers read Ra memory documentation for Dreams +- **THEN** the guidance identifies dream output as generated state and identifies + original sessions plus the input memory store as source evidence diff --git a/openspec/changes/archive/2026-06-02-add-agent-owned-dreams-memory/specs/codex-style-memory-system/spec.md b/openspec/changes/archive/2026-06-02-add-agent-owned-dreams-memory/specs/codex-style-memory-system/spec.md new file mode 100644 index 0000000..ab13031 --- /dev/null +++ b/openspec/changes/archive/2026-06-02-add-agent-owned-dreams-memory/specs/codex-style-memory-system/spec.md @@ -0,0 +1,17 @@ +## ADDED Requirements + +### Requirement: Dream Scheduling Configuration + +Ra SHALL expose a memory configuration threshold for the minimum number of +eligible sessions between agent-owned Dreams. + +#### Scenario: Default dream scheduling threshold + +- **WHEN** Ra loads a minimal config +- **THEN** the memory policy uses a conservative default of 10 sessions between + Dreams + +#### Scenario: Configured dream scheduling threshold + +- **WHEN** Ra loads `[memory] min_sessions_between_dreams` +- **THEN** Ra maps that value into the runtime memory policy diff --git a/openspec/changes/archive/2026-06-02-add-agent-owned-dreams-memory/tasks.md b/openspec/changes/archive/2026-06-02-add-agent-owned-dreams-memory/tasks.md new file mode 100644 index 0000000..bca0a9f --- /dev/null +++ b/openspec/changes/archive/2026-06-02-add-agent-owned-dreams-memory/tasks.md @@ -0,0 +1,15 @@ +## 1. Dreams Policy Layer + +- [x] 1.1 Add agent-owned Dreams scheduling decisions, skip reasons, scheduler, input selection, job state, and output adoption gate. +- [x] 1.2 Keep Dreams outside `MemoryEntryLifecycle` and reuse existing `decide_use` policy for output adoption. + +## 2. Memory Config + +- [x] 2.1 Add `min_sessions_between_dreams` to memory policy/config with default value 10 and runtime mapping. +- [x] 2.2 Regenerate the Ra config schema and update the example config. + +## 3. Tests And Docs + +- [x] 3.1 Add unit tests for dream skip branches, input filtering/cap behavior, completed/non-completed adoption, and config default/parsing. +- [x] 3.2 Add developer documentation explaining the Dreams generated-state/source-evidence boundary and no-live-API scope. +- [x] 3.3 Run relevant tests and OpenSpec validation, then archive the accepted change. diff --git a/openspec/specs/agent-owned-dreams-memory/spec.md b/openspec/specs/agent-owned-dreams-memory/spec.md new file mode 100644 index 0000000..35f8b25 --- /dev/null +++ b/openspec/specs/agent-owned-dreams-memory/spec.md @@ -0,0 +1,81 @@ +# agent-owned-dreams-memory Specification + +## Purpose +TBD - created by archiving change add-agent-owned-dreams-memory. Update Purpose after archive. +## Requirements +### Requirement: Agent-Owned Dream Scheduling + +Ra SHALL expose a pure agent-side Dreams scheduling decision that returns an +explicit decision object instead of silently skipping work. + +#### Scenario: Dream allowed + +- **WHEN** memories are enabled, the region is available, rate-limit headroom is + not below policy, and enough sessions have occurred since the last dream +- **THEN** the scheduling decision allows the agent to start a dream + +#### Scenario: Dream skipped with reason + +- **WHEN** memories are disabled, the region is unavailable, rate-limit headroom + is too low, or too few sessions have occurred since the last dream +- **THEN** the scheduling decision skips the dream and reports the matching + skip reason + +### Requirement: Dream Input Selection + +Ra SHALL allow the agent to select dream input sessions from session candidates +while excluding ineligible sessions and enforcing the Claude Dreams API input +limit. + +#### Scenario: Eligible dream inputs + +- **WHEN** candidate sessions are inactive and meet the configured minimum + session duration +- **THEN** Ra selects them as dream inputs + +#### Scenario: Ineligible and excess dream inputs + +- **WHEN** candidate sessions are active, shorter than the configured minimum + session duration, or exceed the 100-session input cap +- **THEN** Ra excludes active and too-short sessions and returns at most 100 + inputs + +### Requirement: Dream Job State + +Ra SHALL represent agent-owned dream jobs as state that tracks pending, running, +completed, failed, and canceled statuses, the input memory store id, and an +optional output memory store id. + +#### Scenario: Dream job tracks output store + +- **WHEN** a dream job is completed with an output memory store id +- **THEN** Ra preserves both the original input store id and the completed output + store id in the job state + +### Requirement: Dream Output Adoption Gate + +Ra SHALL require completed dream outputs to pass through the existing memory use +policy before they can become active for a future session. + +#### Scenario: Completed output is considered for use + +- **WHEN** a dream job is completed and has an output memory store id +- **THEN** Ra evaluates adoption with the same memory use gate used for generated + durable memory entries + +#### Scenario: Non-completed output is suppressed + +- **WHEN** a dream job is pending, running, failed, or canceled +- **THEN** Ra suppresses adoption instead of treating the output as active + +### Requirement: Dream Evidence Boundary + +Ra SHALL document that dream output is generated memory state while original +sessions and the input memory store remain source evidence. + +#### Scenario: Developer guidance distinguishes generated state and evidence + +- **WHEN** developers read Ra memory documentation for Dreams +- **THEN** the guidance identifies dream output as generated state and identifies + original sessions plus the input memory store as source evidence + diff --git a/openspec/specs/codex-style-memory-system/spec.md b/openspec/specs/codex-style-memory-system/spec.md index 426c139..a16b66b 100644 --- a/openspec/specs/codex-style-memory-system/spec.md +++ b/openspec/specs/codex-style-memory-system/spec.md @@ -108,3 +108,19 @@ existing memoryEntry lifecycle policy before writing artifacts. - **THEN** Ra redacts those fields before writing and records that redaction was applied +### Requirement: Dream Scheduling Configuration + +Ra SHALL expose a memory configuration threshold for the minimum number of +eligible sessions between agent-owned Dreams. + +#### Scenario: Default dream scheduling threshold + +- **WHEN** Ra loads a minimal config +- **THEN** the memory policy uses a conservative default of 10 sessions between + Dreams + +#### Scenario: Configured dream scheduling threshold + +- **WHEN** Ra loads `[memory] min_sessions_between_dreams` +- **THEN** Ra maps that value into the runtime memory policy + diff --git a/spec/ra-config.schema.json b/spec/ra-config.schema.json index 1fcf8ad..b714fa5 100644 --- a/spec/ra-config.schema.json +++ b/spec/ra-config.schema.json @@ -389,6 +389,13 @@ "format": "uint64", "minimum": 0.0 }, + "min_sessions_between_dreams": { + "description": "Minimum completed eligible sessions between agent-owned Dreams.", + "default": 10, + "type": "integer", + "format": "uint", + "minimum": 0.0 + }, "region_available": { "description": "Region availability gate. Exposed for parity with Codex-style policy; default true for local Ra.", "default": true, diff --git a/spec/ra.toml.example b/spec/ra.toml.example index 479548d..e81dd60 100644 --- a/spec/ra.toml.example +++ b/spec/ra.toml.example @@ -200,6 +200,7 @@ mode = "default" # default | plan | ask # min_idle_before_generation_secs = 600 # min_session_duration_secs = 60 # min_rate_limit_remaining_percent = 0 +# min_sessions_between_dreams = 10 # dir = "~/.local/share/ra/memories" # optional override # max_prompt_memories = 20 diff --git a/src/config.rs b/src/config.rs index a44f953..e36a7f2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -456,6 +456,9 @@ pub struct MemorySection { /// this percentage. #[serde(default)] pub min_rate_limit_remaining_percent: u8, + /// Minimum completed eligible sessions between agent-owned Dreams. + #[serde(default = "default_memory_sessions_between_dreams")] + pub min_sessions_between_dreams: usize, /// Optional root for generated memory state. Defaults to /// `/memories`. #[serde(default, alias = "path")] @@ -476,6 +479,7 @@ impl Default for MemorySection { min_idle_before_generation_secs: default_memory_idle_secs(), min_session_duration_secs: default_memory_session_secs(), min_rate_limit_remaining_percent: 0, + min_sessions_between_dreams: default_memory_sessions_between_dreams(), dir: None, max_prompt_memories: default_memory_prompt_limit(), } @@ -490,6 +494,10 @@ fn default_memory_session_secs() -> u64 { 60 } +fn default_memory_sessions_between_dreams() -> usize { + 10 +} + fn default_memory_prompt_limit() -> usize { 20 } @@ -777,6 +785,7 @@ timeout = 2.0 assert!(cfg.memory.generate_memories); assert_eq!(cfg.memory.min_idle_before_generation_secs, 600); assert_eq!(cfg.memory.min_session_duration_secs, 60); + assert_eq!(cfg.memory.min_sessions_between_dreams, 10); let toml_doc = r#" version = 1 @@ -789,6 +798,7 @@ suppress_on_external_context = true min_idle_secs = 5 min_duration_secs = 2 min_rate_limit_remaining_percent = 25 +min_sessions_between_dreams = 7 path = "./.ra/memory" max_prompt_memories = 3 "#; @@ -800,6 +810,7 @@ max_prompt_memories = 3 assert_eq!(cfg.memory.min_idle_before_generation_secs, 5); assert_eq!(cfg.memory.min_session_duration_secs, 2); assert_eq!(cfg.memory.min_rate_limit_remaining_percent, 25); + assert_eq!(cfg.memory.min_sessions_between_dreams, 7); assert_eq!(cfg.memory.dir.as_deref(), Some("./.ra/memory")); assert_eq!(cfg.memory.max_prompt_memories, 3); } diff --git a/src/memory.rs b/src/memory.rs index 90f947c..849ac4d 100644 --- a/src/memory.rs +++ b/src/memory.rs @@ -245,6 +245,7 @@ pub fn policy_from_config(section: &MemorySection) -> MemoryPolicy { min_idle_before_generation: Duration::from_secs(section.min_idle_before_generation_secs), min_session_duration: Duration::from_secs(section.min_session_duration_secs), min_rate_limit_remaining_percent: section.min_rate_limit_remaining_percent, + min_sessions_between_dreams: section.min_sessions_between_dreams, } } diff --git a/src/memory_entry.rs b/src/memory_entry.rs index a65989a..6bf6620 100644 --- a/src/memory_entry.rs +++ b/src/memory_entry.rs @@ -6,6 +6,9 @@ use std::time::Duration; +/// Claude Dreams accepts at most 100 session transcripts per dream job. +pub const DREAM_INPUT_SESSION_CAP: usize = 100; + /// Policy switches and thresholds that apply to memory generation and use. #[derive(Debug, Clone, PartialEq, Eq)] pub struct MemoryPolicy { @@ -17,6 +20,7 @@ pub struct MemoryPolicy { pub min_idle_before_generation: Duration, pub min_session_duration: Duration, pub min_rate_limit_remaining_percent: u8, + pub min_sessions_between_dreams: usize, } impl Default for MemoryPolicy { @@ -30,6 +34,7 @@ impl Default for MemoryPolicy { min_idle_before_generation: Duration::from_secs(10 * 60), min_session_duration: Duration::from_secs(60), min_rate_limit_remaining_percent: 0, + min_sessions_between_dreams: 10, } } } @@ -147,6 +152,156 @@ pub enum MemoryEntryLifecycle { Suppressed(SuppressionReason), } +/// Agent-side policy helper for Claude-style Dreams. +/// +/// Dreams synthesize many sessions and an input memory store into a new output +/// memory store. They are intentionally modeled above `MemoryEntryLifecycle` +/// because they are not a per-entry lifecycle state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DreamScheduler { + policy: MemoryPolicy, +} + +impl DreamScheduler { + pub fn new(policy: MemoryPolicy) -> Self { + Self { policy } + } + + pub fn policy(&self) -> &MemoryPolicy { + &self.policy + } + + /// Decide whether the agent should start a dream now. + /// + /// The agent owns the scheduling loop; this method only evaluates the + /// memory policy gates that make the decision auditable. + pub fn should_dream( + &self, + sessions_since_last_dream: usize, + rate_limit_remaining_percent: Option, + ) -> ShouldDreamDecision { + if !self.policy.memories_enabled { + return ShouldDreamDecision::Skip(DreamSkipReason::MemoriesDisabled); + } + if !self.policy.region_available { + return ShouldDreamDecision::Skip(DreamSkipReason::RegionUnavailable); + } + if rate_limit_remaining_percent + .is_some_and(|remaining| remaining < self.policy.min_rate_limit_remaining_percent) + { + return ShouldDreamDecision::Skip(DreamSkipReason::RateLimitTooLow); + } + if sessions_since_last_dream < self.policy.min_sessions_between_dreams { + return ShouldDreamDecision::Skip(DreamSkipReason::NotEnoughSessions); + } + ShouldDreamDecision::Dream + } + + /// Select eligible past sessions for a dream input batch. + /// + /// Active and too-short sessions are excluded. Idle delay is intentionally + /// ignored here because Dreams consume prior sessions rather than deciding + /// whether a just-finished session may generate an entry. + pub fn select_dream_inputs<'a>( + &self, + candidates: &'a [MemoryCandidate], + ) -> Vec<&'a MemoryCandidate> { + candidates + .iter() + .filter(|candidate| { + !candidate.is_active + && candidate.session_duration >= self.policy.min_session_duration + }) + .take(DREAM_INPUT_SESSION_CAP) + .collect() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShouldDreamDecision { + Dream, + Skip(DreamSkipReason), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DreamSkipReason { + MemoriesDisabled, + RegionUnavailable, + RateLimitTooLow, + NotEnoughSessions, +} + +/// Agent-owned dream job state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DreamJob { + pub dream_id: String, + pub input_store_id: String, + pub output_store_id: Option, + pub status: DreamStatus, +} + +impl DreamJob { + pub fn new(dream_id: impl Into, input_store_id: impl Into) -> Self { + Self { + dream_id: dream_id.into(), + input_store_id: input_store_id.into(), + output_store_id: None, + status: DreamStatus::Pending, + } + } + + pub fn update(&mut self, status: DreamStatus, output_store_id: Option>) { + self.status = status; + if let Some(output_store_id) = output_store_id { + self.output_store_id = Some(output_store_id.into()); + } + } + + /// Decide whether the completed dream output may be adopted for future use. + /// + /// Adoption reuses the same memory use gate as ordinary generated durable + /// memories. Non-completed jobs or completed jobs without an output store do + /// not become active. + pub fn adopt_output( + &self, + policy: &MemoryPolicy, + has_external_context: bool, + ) -> DreamAdoptionDecision { + if self.status != DreamStatus::Completed { + return DreamAdoptionDecision::Suppressed { + reason: SuppressionReason::EntryNotDurable, + }; + } + let Some(output_store_id) = &self.output_store_id else { + return DreamAdoptionDecision::Suppressed { + reason: SuppressionReason::EntryNotDurable, + }; + }; + let output_entry = MemoryEntry::generated(false); + match decide_use(policy, &output_entry, has_external_context) { + UseDecision::Active => DreamAdoptionDecision::Adopted { + output_store_id: output_store_id.clone(), + }, + UseDecision::Suppressed { reason } => DreamAdoptionDecision::Suppressed { reason }, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DreamStatus { + Pending, + Running, + Completed, + Failed { error_type: String }, + Canceled, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DreamAdoptionDecision { + Adopted { output_store_id: String }, + Suppressed { reason: SuppressionReason }, +} + /// Decide whether a thread/session may generate a memory entry. pub fn decide_generation(policy: &MemoryPolicy, candidate: &MemoryCandidate) -> GenerationDecision { if let Some(reason) = shared_suppression(policy, candidate.has_external_context) { diff --git a/tests/memory_entry.rs b/tests/memory_entry.rs index 2788bb5..4eb3cd0 100644 --- a/tests/memory_entry.rs +++ b/tests/memory_entry.rs @@ -1,9 +1,10 @@ use std::time::Duration; use ra::memory_entry::{ - decide_generation, decide_use, generation_lifecycle, use_lifecycle, GenerationDecision, - MemoryCandidate, MemoryEntry, MemoryEntryLifecycle, MemoryPolicy, PendingReason, - SuppressionReason, UseDecision, + decide_generation, decide_use, generation_lifecycle, use_lifecycle, DreamAdoptionDecision, + DreamJob, DreamScheduler, DreamSkipReason, DreamStatus, GenerationDecision, MemoryCandidate, + MemoryEntry, MemoryEntryLifecycle, MemoryPolicy, PendingReason, ShouldDreamDecision, + SuppressionReason, UseDecision, DREAM_INPUT_SESSION_CAP, }; fn enabled_policy() -> MemoryPolicy { @@ -238,3 +239,110 @@ fn generated_entries_record_redaction_and_guidance() { "AGENTS.md or checked-in documentation" ); } + +#[test] +fn dream_scheduler_reports_each_skip_branch() { + let scheduler = DreamScheduler::new(MemoryPolicy { + memories_enabled: false, + ..enabled_policy() + }); + assert_eq!( + scheduler.should_dream(10, Some(80)), + ShouldDreamDecision::Skip(DreamSkipReason::MemoriesDisabled) + ); + + let scheduler = DreamScheduler::new(MemoryPolicy { + region_available: false, + ..enabled_policy() + }); + assert_eq!( + scheduler.should_dream(10, Some(80)), + ShouldDreamDecision::Skip(DreamSkipReason::RegionUnavailable) + ); + + let scheduler = DreamScheduler::new(enabled_policy()); + assert_eq!( + scheduler.should_dream(10, Some(19)), + ShouldDreamDecision::Skip(DreamSkipReason::RateLimitTooLow) + ); + assert_eq!( + scheduler.should_dream(9, Some(80)), + ShouldDreamDecision::Skip(DreamSkipReason::NotEnoughSessions) + ); + assert_eq!( + scheduler.should_dream(10, Some(80)), + ShouldDreamDecision::Dream + ); +} + +#[test] +fn dream_input_selection_filters_active_short_and_caps_inputs() { + let scheduler = DreamScheduler::new(enabled_policy()); + let mut candidates = Vec::new(); + candidates.push(MemoryCandidate { + is_active: true, + ..mature_candidate() + }); + candidates.push(MemoryCandidate { + session_duration: Duration::from_secs(119), + ..mature_candidate() + }); + for _ in 0..(DREAM_INPUT_SESSION_CAP + 5) { + candidates.push(mature_candidate()); + } + + let selected = scheduler.select_dream_inputs(&candidates); + + assert_eq!(selected.len(), DREAM_INPUT_SESSION_CAP); + assert!(selected.iter().all(|candidate| !candidate.is_active)); + assert!(selected + .iter() + .all(|candidate| candidate.session_duration >= scheduler.policy().min_session_duration)); +} + +#[test] +fn completed_dream_output_reuses_memory_use_gate() { + let mut job = DreamJob::new("dream-1", "input-store"); + job.update(DreamStatus::Completed, Some("output-store")); + + assert_eq!( + job.adopt_output(&enabled_policy(), false), + DreamAdoptionDecision::Adopted { + output_store_id: "output-store".into() + } + ); + assert_eq!(job.input_store_id, "input-store"); + + let no_use_policy = MemoryPolicy { + use_memories: false, + ..enabled_policy() + }; + assert_eq!( + job.adopt_output(&no_use_policy, false), + DreamAdoptionDecision::Suppressed { + reason: SuppressionReason::ThreadUseDisabled + } + ); +} + +#[test] +fn non_completed_or_missing_dream_output_is_not_adopted() { + let policy = enabled_policy(); + let mut running = DreamJob::new("dream-1", "input-store"); + running.update(DreamStatus::Running, Some("partial-output")); + assert_eq!( + running.adopt_output(&policy, false), + DreamAdoptionDecision::Suppressed { + reason: SuppressionReason::EntryNotDurable + } + ); + + let mut completed_without_output = DreamJob::new("dream-2", "input-store"); + completed_without_output.update(DreamStatus::Completed, None::); + assert_eq!( + completed_without_output.adopt_output(&policy, false), + DreamAdoptionDecision::Suppressed { + reason: SuppressionReason::EntryNotDurable + } + ); +} diff --git a/tests/memory_system.rs b/tests/memory_system.rs index 859fe8d..3fe93e2 100644 --- a/tests/memory_system.rs +++ b/tests/memory_system.rs @@ -28,6 +28,7 @@ enabled = true min_idle_before_generation_secs = 0 min_session_duration_secs = 0 min_rate_limit_remaining_percent = 20 +min_sessions_between_dreams = 7 max_prompt_memories = 5 "#, ) @@ -61,6 +62,7 @@ fn memory_config_maps_to_lifecycle_policy_and_thread_controls() { assert!(policy.use_memories); assert!(policy.generate_memories); assert_eq!(policy.min_rate_limit_remaining_percent, 20); + assert_eq!(policy.min_sessions_between_dreams, 7); let thread_policy = policy_for_thread( &policy, From 61884a9e48829cbe7ab689aa6929c8a41496e77f Mon Sep 17 00:00:00 2001 From: local Date: Tue, 2 Jun 2026 16:06:47 +0800 Subject: [PATCH 2/2] Fix Dreams memory policy edge cases --- src/memory_entry.rs | 9 ++++++--- tests/memory_entry.rs | 35 ++++++++++++++++++++++++++++++++--- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/memory_entry.rs b/src/memory_entry.rs index 6bf6620..5c6920f 100644 --- a/src/memory_entry.rs +++ b/src/memory_entry.rs @@ -186,6 +186,9 @@ impl DreamScheduler { if !self.policy.region_available { return ShouldDreamDecision::Skip(DreamSkipReason::RegionUnavailable); } + if !self.policy.generate_memories { + return ShouldDreamDecision::Skip(DreamSkipReason::GenerationDisabled); + } if rate_limit_remaining_percent .is_some_and(|remaining| remaining < self.policy.min_rate_limit_remaining_percent) { @@ -211,6 +214,7 @@ impl DreamScheduler { .filter(|candidate| { !candidate.is_active && candidate.session_duration >= self.policy.min_session_duration + && !(self.policy.disable_on_external_context && candidate.has_external_context) }) .take(DREAM_INPUT_SESSION_CAP) .collect() @@ -227,6 +231,7 @@ pub enum ShouldDreamDecision { pub enum DreamSkipReason { MemoriesDisabled, RegionUnavailable, + GenerationDisabled, RateLimitTooLow, NotEnoughSessions, } @@ -252,9 +257,7 @@ impl DreamJob { pub fn update(&mut self, status: DreamStatus, output_store_id: Option>) { self.status = status; - if let Some(output_store_id) = output_store_id { - self.output_store_id = Some(output_store_id.into()); - } + self.output_store_id = output_store_id.map(Into::into); } /// Decide whether the completed dream output may be adopted for future use. diff --git a/tests/memory_entry.rs b/tests/memory_entry.rs index 4eb3cd0..256ac41 100644 --- a/tests/memory_entry.rs +++ b/tests/memory_entry.rs @@ -260,6 +260,15 @@ fn dream_scheduler_reports_each_skip_branch() { ShouldDreamDecision::Skip(DreamSkipReason::RegionUnavailable) ); + let scheduler = DreamScheduler::new(MemoryPolicy { + generate_memories: false, + ..enabled_policy() + }); + assert_eq!( + scheduler.should_dream(10, Some(80)), + ShouldDreamDecision::Skip(DreamSkipReason::GenerationDisabled) + ); + let scheduler = DreamScheduler::new(enabled_policy()); assert_eq!( scheduler.should_dream(10, Some(19)), @@ -277,7 +286,6 @@ fn dream_scheduler_reports_each_skip_branch() { #[test] fn dream_input_selection_filters_active_short_and_caps_inputs() { - let scheduler = DreamScheduler::new(enabled_policy()); let mut candidates = Vec::new(); candidates.push(MemoryCandidate { is_active: true, @@ -287,17 +295,28 @@ fn dream_input_selection_filters_active_short_and_caps_inputs() { session_duration: Duration::from_secs(119), ..mature_candidate() }); + candidates.push(MemoryCandidate { + has_external_context: true, + ..mature_candidate() + }); for _ in 0..(DREAM_INPUT_SESSION_CAP + 5) { candidates.push(mature_candidate()); } - let selected = scheduler.select_dream_inputs(&candidates); + let selected = DreamScheduler::new(MemoryPolicy { + disable_on_external_context: true, + ..enabled_policy() + }) + .select_dream_inputs(&candidates); assert_eq!(selected.len(), DREAM_INPUT_SESSION_CAP); assert!(selected.iter().all(|candidate| !candidate.is_active)); assert!(selected .iter() - .all(|candidate| candidate.session_duration >= scheduler.policy().min_session_duration)); + .all(|candidate| !candidate.has_external_context)); + assert!(selected + .iter() + .all(|candidate| candidate.session_duration >= enabled_policy().min_session_duration)); } #[test] @@ -345,4 +364,14 @@ fn non_completed_or_missing_dream_output_is_not_adopted() { reason: SuppressionReason::EntryNotDurable } ); + + let mut stale_output = DreamJob::new("dream-3", "input-store"); + stale_output.update(DreamStatus::Running, Some("partial-output")); + stale_output.update(DreamStatus::Completed, None::); + assert_eq!( + stale_output.adopt_output(&policy, false), + DreamAdoptionDecision::Suppressed { + reason: SuppressionReason::EntryNotDurable + } + ); }