Skip to main content

harness_loop/experience/
guide.rs

1//! `ExperienceGuide` — before each run (and each model turn), recall similar
2//! past episodes and inject them so the model can reuse what worked before.
3//!
4//! Mirrors `harness-loop`'s `MemoryGuide` conventions: a marker-prefixed text
5//! block in `ctx.guides`, stripped and refreshed on every iteration so the
6//! injection tracks the latest query without growing unbounded.
7
8use crate::experience::store::ExperienceStore;
9use async_trait::async_trait;
10use harness_core::{Block, Context, Execution, Guide, GuideError, GuideId, GuideScope, World};
11use std::sync::{Arc, OnceLock};
12
13const MARKER: &str = "[experience-recall]\n";
14
15/// Guide that injects recalled experience episodes into the prompt.
16pub struct ExperienceGuide {
17    store: Arc<ExperienceStore>,
18    top_k: usize,
19}
20
21static GUIDE_ID: OnceLock<GuideId> = OnceLock::new();
22static GUIDE_SCOPE: OnceLock<GuideScope> = OnceLock::new();
23
24impl ExperienceGuide {
25    pub fn new(store: Arc<ExperienceStore>) -> Self {
26        Self { store, top_k: 3 }
27    }
28
29    /// How many past episodes to recall per query (default 3). Keep it small —
30    /// each recalled episode spends prompt tokens.
31    pub fn with_top_k(mut self, k: usize) -> Self {
32        self.top_k = k;
33        self
34    }
35
36    async fn block(&self, query: &str) -> Option<String> {
37        let eps = self.store.recall(query, self.top_k).await;
38        if eps.is_empty() {
39            return None;
40        }
41        let mut s = String::from(MARKER);
42        s.push_str("Similar past experience — how you handled comparable situations before:");
43        for (i, ep) in eps.iter().enumerate() {
44            let tools = if ep.tools.is_empty() {
45                "(none)".to_string()
46            } else {
47                ep.tools.join(", ")
48            };
49            s.push_str(&format!(
50                "\n  {}. situation: {}\n     tools used: {}\n     outcome: {}",
51                i + 1,
52                ep.situation.trim(),
53                tools,
54                ep.outcome.trim(),
55            ));
56        }
57        Some(s)
58    }
59
60    fn remove_previous(ctx: &mut Context) {
61        ctx.guides
62            .retain(|b| !matches!(b, Block::Text(t) if t.starts_with(MARKER)));
63    }
64}
65
66fn last_user_text(ctx: &Context) -> Option<String> {
67    use harness_core::{Block as B, TurnRole};
68    for turn in ctx.history.iter().rev() {
69        if turn.role != TurnRole::User {
70            continue;
71        }
72        for block in turn.blocks.iter().rev() {
73            if let B::Text(t) = block
74                && !t.trim().is_empty()
75            {
76                return Some(t.clone());
77            }
78        }
79    }
80    None
81}
82
83#[async_trait]
84impl Guide for ExperienceGuide {
85    fn id(&self) -> &GuideId {
86        GUIDE_ID.get_or_init(|| "experience-recall".into())
87    }
88    fn kind(&self) -> Execution {
89        Execution::Computational
90    }
91    fn scope(&self) -> &GuideScope {
92        GUIDE_SCOPE.get_or_init(|| GuideScope::Always)
93    }
94    async fn apply(&self, ctx: &mut Context, _w: &World) -> Result<(), GuideError> {
95        Self::remove_previous(ctx);
96        if let Some(block) = self.block(&ctx.task.description).await {
97            ctx.guides.push(Block::Text(block));
98        }
99        Ok(())
100    }
101    async fn apply_before_iter(&self, ctx: &mut Context, _w: &World) -> Result<(), GuideError> {
102        let query = last_user_text(ctx).unwrap_or_else(|| ctx.task.description.clone());
103        Self::remove_previous(ctx);
104        if let Some(block) = self.block(&query).await {
105            ctx.guides.push(Block::Text(block));
106        }
107        Ok(())
108    }
109}