Skip to main content

harness_loop/
learning.rs

1//! Self-evolving learning loop for [`crate::AgentLoop`].
2//!
3//! After a session does real work, a forked review subagent — white-listed to
4//! skill-write + memory-write tools — reviews the transcript and writes/patches
5//! skills + memory. See [`LearningConfig`] and `AgentLoop::with_learning_loop`.
6
7use harness_core::{Block, Model, Tool, Turn, TurnRole};
8use std::sync::Arc;
9
10/// Configuration for the learning loop. The app injects the review model + the
11/// white-listed tools the review subagent may call (typically a `SkillManageTool`
12/// + a `RememberThisTool`); harness-loop never depends on those crates.
13pub struct LearningConfig {
14    pub review_model: Arc<dyn Model>,
15    pub tools: Vec<Arc<dyn Tool>>,
16    pub review_prompt: String,
17    pub nudge_interval: u32,
18    pub max_iters: u32,
19}
20
21impl LearningConfig {
22    pub fn new(review_model: Arc<dyn Model>) -> Self {
23        Self {
24            review_model,
25            tools: Vec::new(),
26            review_prompt: DEFAULT_REVIEW_PROMPT.to_string(),
27            nudge_interval: 10,
28            max_iters: 6,
29        }
30    }
31    pub fn with_tool(mut self, t: Arc<dyn Tool>) -> Self {
32        self.tools.push(t);
33        self
34    }
35    pub fn with_nudge_interval(mut self, n: u32) -> Self {
36        self.nudge_interval = n;
37        self
38    }
39    pub fn with_review_prompt(mut self, p: impl Into<String>) -> Self {
40        self.review_prompt = p.into();
41        self
42    }
43    pub fn with_max_iters(mut self, n: u32) -> Self {
44        self.max_iters = n;
45        self
46    }
47}
48
49/// Default review prompt — adapted from Hermes Agent's skill+memory review.
50pub const DEFAULT_REVIEW_PROMPT: &str = "\
51You are a BACKGROUND REVIEWER running after a session finished. Using ONLY the \
52tools provided (skill management + memory), update the skill library and memory \
53based on the conversation transcript below. Make at most a few focused changes.\n\n\
54Be active — most sessions that did real work produce at least one small update; a \
55pass that does nothing is a missed learning opportunity, not a neutral outcome. \
56But 'nothing to save' IS a valid result for a trivial session — if so, do nothing.\n\n\
57SKILLS (procedural memory): when a non-trivial technique, fix, workflow, or \
58correction emerged that a future session would reuse, capture it as a skill with \
59skill_manage. Prefer CLASS-LEVEL umbrella skills with a rich body (trigger \
60conditions, numbered steps with exact commands, a pitfalls section). The name must \
61be class-level (e.g. 'deploy-runbook'), NEVER a one-off ('fix-bug-1234'). If an \
62existing skill covers the territory, PATCH it (add a step or pitfall) instead of \
63creating a new one.\n\n\
64MEMORY (about the user): if the user revealed durable preferences, working style, \
65identity, or expectations about how you should behave ('stop doing X', 'always Y', \
66'remember Z'), save them with the memory tool so the next session starts knowing.\n\n\
67Make your changes, then stop.";
68
69/// Render conversation history into a compact, role-tagged transcript for the
70/// reviewer. Keeps the TAIL within a char budget (recent turns matter most).
71pub fn render_transcript(history: &[Turn], max_chars: usize) -> String {
72    let mut out = String::new();
73    for turn in history {
74        let role = match turn.role {
75            TurnRole::User => "user",
76            TurnRole::Assistant => "assistant",
77            TurnRole::System => "system",
78            TurnRole::Tool => "tool",
79            _ => "unknown",
80        };
81        for b in &turn.blocks {
82            match b {
83                Block::Text(t) => out.push_str(&format!("{role}: {t}\n")),
84                Block::ToolResult { content, .. } => {
85                    out.push_str(&format!("tool_result: {content}\n"))
86                }
87                _ => {} // ToolCall etc. — omit from the review transcript
88            }
89        }
90    }
91    if out.len() > max_chars {
92        let start = out.len() - max_chars;
93        let start = (start..out.len())
94            .find(|i| out.is_char_boundary(*i))
95            .unwrap_or(out.len());
96        out = format!("…(transcript truncated)…\n{}", &out[start..]);
97    }
98    out
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn transcript_renders_roles_and_truncates_tail() {
107        let history = vec![
108            Turn {
109                role: TurnRole::User,
110                blocks: vec![Block::Text("hello there".into())],
111            },
112            Turn {
113                role: TurnRole::Assistant,
114                blocks: vec![Block::Text("hi".into())],
115            },
116        ];
117        let t = render_transcript(&history, 10_000);
118        assert!(t.contains("user: hello there"));
119        assert!(t.contains("assistant: hi"));
120
121        let big = vec![Turn {
122            role: TurnRole::User,
123            blocks: vec![Block::Text("x".repeat(50_000))],
124        }];
125        let t = render_transcript(&big, 1_000);
126        assert!(t.len() < 1_200);
127        assert!(t.starts_with("…(transcript truncated)…"));
128    }
129}