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}