Skip to main content

agent_base/engine/
turn_facts.rs

1use std::sync::Mutex;
2
3use async_trait::async_trait;
4
5use crate::engine::middleware::{Middleware, PostLlmCtx, UserMessageCtx};
6use crate::types::{AgentResult, Language};
7
8/// Turn fact summary middleware — injects structured facts from the previous
9/// turn's tool results into the next user message.
10///
11/// This prevents long-conversation attention drift: the LLM sees deterministic
12/// facts (tool call names) instead of relying on fuzzy memory of tool outputs
13/// buried 20+ turns ago.
14///
15/// # Design
16///
17/// - After each LLM turn with tool calls, collects which tools were called
18///   and stores them in a buffer.
19/// - At the start of the next user message, prepends the buffered facts as a
20///   structured prefix, then clears the buffer.
21/// - Facts are derived from tool names only (not parsing output text), keeping
22///   the logic simple and model-agnostic.
23/// - The prefix language can be configured via [`Language`]; defaults to
24///   [`Language::Zh`] for backward compatibility.
25pub struct TurnFactMiddleware {
26    pending_facts: Mutex<Vec<String>>,
27    language: Language,
28}
29
30impl TurnFactMiddleware {
31    /// Create a new middleware with the default language (Chinese).
32    pub fn new() -> Self {
33        Self {
34            pending_facts: Mutex::new(Vec::new()),
35            language: Language::Zh,
36        }
37    }
38
39    /// Create a new middleware with the specified language for the prefix text.
40    pub fn with_language(language: Language) -> Self {
41        Self {
42            pending_facts: Mutex::new(Vec::new()),
43            language,
44        }
45    }
46}
47
48impl Default for TurnFactMiddleware {
49    fn default() -> Self {
50        Self::new()
51    }
52}
53
54#[async_trait]
55impl Middleware for TurnFactMiddleware {
56    async fn on_user_message(&self, ctx: &mut UserMessageCtx) -> AgentResult<()> {
57        let facts = {
58            let mut guard = self.pending_facts.lock().unwrap();
59            if guard.is_empty() {
60                return Ok(());
61            }
62            std::mem::take(&mut *guard)
63        };
64
65        // Prepend facts to user message as structured context
66        let prefix = match self.language {
67            Language::Zh => format!(
68                "[本轮工具调用摘要 — 以下为确定性事实,请以此为准]\n{}\n",
69                facts.join("\n")
70            ),
71            Language::En => format!(
72                "[Previous turn tool-call summary — treat these as ground truth]\n{}\n",
73                facts.join("\n")
74            ),
75        };
76        ctx.user_input = format!("{prefix}\n{original}", original = ctx.user_input);
77
78        Ok(())
79    }
80
81    async fn on_post_llm(&self, ctx: &mut PostLlmCtx) -> AgentResult<()> {
82        if ctx.tool_calls.is_empty() {
83            return Ok(());
84        }
85
86        let mut facts = Vec::new();
87        for (_id, name, _args) in &ctx.tool_calls {
88            // Record which tools were called — the actual results are in the
89            // session history, but a compact reminder helps the LLM stay grounded.
90            let fact = match self.language {
91                Language::Zh => format!("- 调用了工具: {name}"),
92                Language::En => format!("- Called tool: {name}"),
93            };
94            facts.push(fact);
95        }
96
97        let mut guard = self.pending_facts.lock().unwrap();
98        guard.extend(facts);
99
100        Ok(())
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use crate::types::SessionId;
108
109    #[tokio::test]
110    async fn test_no_facts_no_prefix() {
111        let mw = TurnFactMiddleware::new();
112        let mut ctx = UserMessageCtx {
113            session_id: SessionId::new(1),
114            user_input: "hello".to_string(),
115        };
116        mw.on_user_message(&mut ctx).await.unwrap();
117        assert_eq!(ctx.user_input, "hello");
118    }
119
120    #[tokio::test]
121    async fn test_facts_injected_on_next_user_message() {
122        let mw = TurnFactMiddleware::new();
123
124        // Simulate a tool call turn
125        let mut post_ctx = PostLlmCtx {
126            session_id: SessionId::new(1),
127            full_text: String::new(),
128            is_tool_call: true,
129            tool_calls: vec![
130                ("id1".into(), "execute_ssh_command".into(), "{}".into()),
131                ("id2".into(), "start_interactive_task".into(), "{}".into()),
132            ],
133            available_tools: vec![],
134            turn_count: 1,
135            total_tool_calls: 0,
136            nudge_count: 0,
137            turn_tool_calls: 0,
138            skip_push: false,
139            follow_up_message: None,
140        };
141        mw.on_post_llm(&mut post_ctx).await.unwrap();
142
143        // Next user message should have facts prepended
144        let mut user_ctx = UserMessageCtx {
145            session_id: SessionId::new(1),
146            user_input: "继续执行".to_string(),
147        };
148        mw.on_user_message(&mut user_ctx).await.unwrap();
149
150        assert!(user_ctx.user_input.contains("本轮工具调用摘要"));
151        assert!(user_ctx.user_input.contains("execute_ssh_command"));
152        assert!(user_ctx.user_input.contains("start_interactive_task"));
153        assert!(user_ctx.user_input.contains("继续执行"));
154    }
155
156    #[tokio::test]
157    async fn test_facts_cleared_after_injection() {
158        let mw = TurnFactMiddleware::new();
159
160        // First turn with tool calls
161        let mut post_ctx = PostLlmCtx {
162            session_id: SessionId::new(1),
163            full_text: String::new(),
164            is_tool_call: true,
165            tool_calls: vec![("id1".into(), "docker".into(), "{}".into())],
166            available_tools: vec![],
167            turn_count: 1,
168            total_tool_calls: 0,
169            nudge_count: 0,
170            turn_tool_calls: 0,
171            skip_push: false,
172            follow_up_message: None,
173        };
174        mw.on_post_llm(&mut post_ctx).await.unwrap();
175
176        // First user message gets facts
177        let mut ctx1 = UserMessageCtx {
178            session_id: SessionId::new(1),
179            user_input: "next".into(),
180        };
181        mw.on_user_message(&mut ctx1).await.unwrap();
182        assert!(ctx1.user_input.contains("本轮工具调用摘要"));
183
184        // Second user message should NOT have facts (cleared)
185        let mut ctx2 = UserMessageCtx {
186            session_id: SessionId::new(1),
187            user_input: "again".into(),
188        };
189        mw.on_user_message(&mut ctx2).await.unwrap();
190        assert_eq!(ctx2.user_input, "again");
191    }
192
193    #[tokio::test]
194    async fn test_english_language_prefix() {
195        let mw = TurnFactMiddleware::with_language(Language::En);
196
197        let mut post_ctx = PostLlmCtx {
198            session_id: SessionId::new(1),
199            full_text: String::new(),
200            is_tool_call: true,
201            tool_calls: vec![("id1".into(), "docker".into(), "{}".into())],
202            available_tools: vec![],
203            turn_count: 1,
204            total_tool_calls: 0,
205            nudge_count: 0,
206            turn_tool_calls: 0,
207            skip_push: false,
208            follow_up_message: None,
209        };
210        mw.on_post_llm(&mut post_ctx).await.unwrap();
211
212        let mut ctx = UserMessageCtx {
213            session_id: SessionId::new(1),
214            user_input: "continue".into(),
215        };
216        mw.on_user_message(&mut ctx).await.unwrap();
217
218        assert!(ctx.user_input.contains("Previous turn tool-call summary"));
219        assert!(ctx.user_input.contains("ground truth"));
220        assert!(ctx.user_input.contains("continue"));
221    }
222}