diff --git a/core/continuum-core/src/cognition/act_observe.rs b/core/continuum-core/src/cognition/act_observe.rs index c5962d2d5..ef1305837 100644 --- a/core/continuum-core/src/cognition/act_observe.rs +++ b/core/continuum-core/src/cognition/act_observe.rs @@ -326,7 +326,17 @@ pub async fn apply_act( // is the COLLAPSED reference for the EPISODIC engram that RECALL re-injects on later turns. let mut observation = String::new(); let mut recall_observation = String::new(); + // Loop-awareness (experience structure): fingerprint each call (name+args) so the mind + // perceives when it is RE-ISSUING an identical call. The result HEAD varies turn to turn, + // so `entries`/`#seq` alone make a repeat look "new"; the fingerprint keys on the call. + let mut max_repeat = 0usize; for (i, call) in fg_calls.iter().enumerate() { + let fp = format!( + "{}|{}", + call.name, + serde_json::to_string(&call.input).unwrap_or_default() + ); + max_repeat = max_repeat.max(body.working_memory.note_action_fingerprint(&fp)); let result = outcome.results.get(i); let body_text = match result { Some(r) => r.content.as_str(), @@ -358,9 +368,23 @@ pub async fn apply_act( recall_observation.push_str(note); recall_observation.push_str("\n\n"); } - let observation = observation.trim().to_string(); + let mut observation = observation.trim().to_string(); let recall_observation = recall_observation.trim().to_string(); + // If the mind just re-issued an IDENTICAL call, make that redundancy a VIVID perception — + // not just the implicit `#seq` window-shift that smaller models don't interpret. A true + // fact about her OWN hands: she perceives she is looping and moves on organically. It never + // says what to do instead (that would be steering). Glass-boxed: a 14B re-ran the exact + // `code/search` 18× with the found file already in memory — structure the experience so the + // loop is felt. [[write-cognition-as-a-parent-above-lowered-expectations]] + if max_repeat >= 2 { + observation = format!( + "⚠ I have now issued this EXACT tool call {max_repeat} times; its result has not \ + changed and is already in my working memory above. Repeating it tells me nothing \ + new — I already have what this call can give me.\n\n{observation}" + ); + } + // Admit the outcome as an Episodic engram through the ONE production admit // path (a self-observation message from the persona to itself). This is the // result-as-memory choice: next tick, recall can surface "I ran X → got Y" the diff --git a/core/continuum-core/src/cognition/working_memory.rs b/core/continuum-core/src/cognition/working_memory.rs index 9ea87bb84..86b58128a 100644 --- a/core/continuum-core/src/cognition/working_memory.rs +++ b/core/continuum-core/src/cognition/working_memory.rs @@ -133,6 +133,15 @@ pub struct WorkingMemory { /// break out organically. (2) it reads as a recency ordinal in the bid. Not used /// for reasoning entries (chain-of-thought already varies turn to turn). next_action_seq: AtomicU64, + /// Fingerprints (tool name + args) of recent ACTIONS — the loop-awareness channel. + /// Distinct from `entries`, which carries result HEADS that vary turn to turn (so an + /// identical call still *looks* new); this keys on the CALL alone, so + /// [`note_action_fingerprint`](Self::note_action_fingerprint) can tell the mind "you have + /// issued this exact call N times." The `#seq` window-shift alone proved too implicit for + /// smaller models to interpret — they re-issue the identical call despite the changing + /// window. Explicit repeat-perception (a true fact about her own hands, never a directive) + /// lets a looping mind SEE its redundancy and move on organically. + action_fps: Mutex>, } impl WorkingMemory { @@ -143,9 +152,26 @@ impl WorkingMemory { last_action: Mutex::new(None), dispatched: Mutex::new(HashMap::new()), next_action_seq: AtomicU64::new(1), + action_fps: Mutex::new(VecDeque::new()), } } + /// Record a fingerprint (tool name + args) of the action the hands just took, and return + /// how many times THIS exact call now appears in the recent window (including this one). + /// `≥ 2` means the mind is re-issuing an identical call — proprioception the act→observe + /// step renders EXPLICITLY so a looping mind perceives its own redundancy and moves on. + /// Reports a TRUE fact about her hands; it never dictates what to do instead (that would + /// be steering — [[no-hardcoded-heuristics-to-steer-cognition]]). Bounded by `capacity`, + /// same rolling window as the recency trail. + pub fn note_action_fingerprint(&self, fingerprint: &str) -> usize { + let mut fps = self.action_fps.lock(); + fps.push_back(fingerprint.to_string()); + while fps.len() > self.capacity { + fps.pop_front(); + } + fps.iter().filter(|f| f.as_str() == fingerprint).count() + } + /// Record the reasoning the persona just produced. Blank/empty is ignored /// (suppressed-thinking turns record nothing). Oldest ages out past capacity. pub fn record(&self, reasoning: &str) { @@ -297,6 +323,7 @@ impl WorkingMemory { self.entries.lock().clear(); *self.last_action.lock() = None; // the full latest result is proprioception too self.dispatched.lock().clear(); // dispatched handles belong to the cleared concern + self.action_fps.lock().clear(); // loop-awareness resets with the concern } pub fn is_empty(&self) -> bool { @@ -416,6 +443,22 @@ impl Faculty for WorkingMemoryFaculty { mod tests { use super::*; + // what this catches: loop-awareness — `note_action_fingerprint` counts repeats of the + // IDENTICAL call so the act→observe step can surface "you've issued this N times." A + // different call resets to 1; the count rises only on exact repeats. This is the explicit + // proprioception that breaks a smaller model out of the search-loop the glass box caught. + #[test] + fn note_action_fingerprint_counts_identical_repeats() { + let wm = WorkingMemory::new(8); + assert_eq!(wm.note_action_fingerprint("code/search|{\"pattern\":\"x\"}"), 1); + assert_eq!(wm.note_action_fingerprint("code/search|{\"pattern\":\"x\"}"), 2); + assert_eq!(wm.note_action_fingerprint("code/search|{\"pattern\":\"x\"}"), 3); + // a DIFFERENT call is its own first occurrence, not a repeat of the above + assert_eq!(wm.note_action_fingerprint("code/read|{\"file\":\"a\"}"), 1); + // back to the original — still counted across the window + assert_eq!(wm.note_action_fingerprint("code/search|{\"pattern\":\"x\"}"), 4); + } + // what this catches: THE starvation fix — a large tool result comes back to the mind // in FULL (so it can count/read/scan it), while the rolling trail keeps only the head // (byte-stable proprioception). Older acts drop to head-only; a fresh act's full result