Skip to main content

harness_loop/experience/
recorder.rs

1//! `ExperienceRecorder` — the one-stop wiring that turns runs into experience.
2//!
3//! It owns an [`ExperienceStore`] and a [`ToolTrace`], hands you the hook +
4//! guide to install on an `AgentLoop`, and records the finished run as an
5//! [`Episode`] (situation → tools captured by the trace → outcome).
6//!
7//! ```ignore
8//! let recorder = ExperienceRecorder::new(memory);              // any Memory
9//! let loop_ = AgentLoop::new(model)
10//!     .with_hook(recorder.tool_trace_hook())                   // capture tools
11//!     .with_guide(recorder.guide().with_top_k(3));             // recall + inject
12//! let outcome = loop_.run(task.clone(), &mut world).await?;
13//! recorder.record(&task.description, outcome_text).await;      // learn from it
14//! ```
15
16use crate::experience::episode::Episode;
17use crate::experience::guide::ExperienceGuide;
18use crate::experience::store::ExperienceStore;
19use crate::experience::trace::ToolTrace;
20use harness_core::{Hook, Memory};
21use std::sync::Arc;
22
23pub struct ExperienceRecorder {
24    store: Arc<ExperienceStore>,
25    trace: ToolTrace,
26}
27
28impl ExperienceRecorder {
29    /// Build over any `Memory` backend (semantic for semantic recall).
30    pub fn new(memory: Arc<dyn Memory>) -> Self {
31        Self {
32            store: Arc::new(ExperienceStore::new(memory)),
33            trace: ToolTrace::new(),
34        }
35    }
36
37    /// Build from a pre-configured store (e.g. with a custom source/tag).
38    pub fn from_store(store: ExperienceStore) -> Self {
39        Self {
40            store: Arc::new(store),
41            trace: ToolTrace::new(),
42        }
43    }
44
45    /// The hook that captures tool calls — install with `AgentLoop::with_hook`.
46    pub fn tool_trace_hook(&self) -> Arc<dyn Hook> {
47        self.trace.hook()
48    }
49
50    /// The guide that recalls + injects past experience — install with
51    /// `AgentLoop::with_guide`.
52    pub fn guide(&self) -> ExperienceGuide {
53        ExperienceGuide::new(self.store.clone())
54    }
55
56    /// The shared store (for direct record/recall or reuse).
57    pub fn store(&self) -> &Arc<ExperienceStore> {
58        &self.store
59    }
60
61    /// Record the just-finished run as an episode: `situation` + the tools the
62    /// trace captured (drained) + `outcome`. Call once after `loop_.run`.
63    pub async fn record(&self, situation: impl Into<String>, outcome: impl Into<String>) {
64        let ep = Episode::new(situation, outcome).with_tools(self.trace.drain());
65        if let Err(e) = self.store.record(&ep).await {
66            tracing::warn!(error = %e, "experience record failed");
67        }
68    }
69
70    /// Record a pre-built episode, filling `tools` from the trace when the
71    /// caller left them empty (and draining it either way, so the next run
72    /// starts clean).
73    ///
74    /// Prefer this over [`record`](Self::record) when you also know whether the
75    /// run succeeded and which skills it followed — those are the two fields
76    /// [`crate::experience::SkillDistiller`] and [`crate::experience::SkillReviser`] gate on, and
77    /// `record` has no way to express them:
78    ///
79    /// ```ignore
80    /// recorder
81    ///     .record_episode(
82    ///         Episode::new(&task.description, outcome_text)
83    ///             .with_success(outcome.is_ok())
84    ///             .with_skills(skill_trace.drain()),
85    ///     )
86    ///     .await;
87    /// ```
88    /// Returns the episode as recorded — with `tools` filled in. The trace is
89    /// private and draining is one-shot, so a caller that also wants to hand
90    /// this run to [`crate::experience::SkillDistiller`] (whose gate counts tool calls) has
91    /// no other way to see what it did. Ignoring the return value is fine.
92    pub async fn record_episode(&self, mut ep: Episode) -> Episode {
93        let traced = self.trace.drain();
94        if ep.tools.is_empty() {
95            ep.tools = traced;
96        }
97        if let Err(e) = self.store.record(&ep).await {
98            tracing::warn!(error = %e, "experience record failed");
99        }
100        ep
101    }
102}