lc-agents 0.20.0

Agent system for langchainrust — ReAct, FunctionCalling, PlanExecute, CRAG, AdaptiveRAG, DeepResearch, Handoffs, Streaming
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
// lc-agents/src/hooks/mod.rs
//! Agent Hook/Middleware system for composable lifecycle interception.
//!
//! Hooks allow injecting custom behavior at key points in the agent execution
//! loop: before/after LLM calls, before/after tool calls, on stream tokens,
//! and on errors.
//!
//! # Example
//!
//! ```rust,ignore
//! use lc_agents::hooks::{AgentHook, ApprovalHook, ContentFilterHook};
//! use lc_agents::AgentExecutor;
//!
//! let executor = AgentExecutor::new(agent, tools)
//!     .hook(ApprovalHook::new())           // Require approval before tool calls
//!     .hook(ContentFilterHook::new(words)); // Filter sensitive words from stream
//! ```

mod approval;
mod content_filter;
mod injection;
mod logging;
mod rate_limit;

pub use approval::ApprovalHook;
pub use content_filter::ContentFilterHook;
pub use injection::PromptInjectionHook;
pub use logging::LoggingHook;
pub use rate_limit::TokenBudgetHook;

use async_trait::async_trait;
use lc_schema::Message;
use serde_json::Value;
use std::collections::HashMap;

/// Error type for hook operations.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum HookError {
    /// The hook rejected the operation.
    #[error("Hook rejected: {0}")]
    Rejected(String),

    /// The hook encountered an error.
    #[error("Hook error: {0}")]
    Other(String),
}

/// Action to take for a completion (LLM call).
#[derive(Debug, Clone)]
pub enum CompletionAction {
    /// Allow the completion to proceed.
    Continue,
    /// Modify the messages before the LLM call.
    Modify {
        /// The replacement messages to send to the LLM.
        messages: Vec<Message>,
    },
    /// Reject the LLM call entirely.
    Reject {
        /// The reason for the rejection.
        reason: String,
    },
}

/// Action to take for a tool call.
#[derive(Debug, Clone)]
pub enum ToolCallAction {
    /// Allow the tool call to proceed.
    Continue,
    /// Modify the tool call parameters.
    Modify {
        /// The tool name.
        name: String,
        /// The modified tool call arguments.
        arguments: Value,
    },
    /// Reject the tool call.
    Reject {
        /// The reason for the rejection.
        reason: String,
    },
    /// Skip this tool call (don't execute, don't error).
    Skip,
}

/// Action to take for a stream chunk.
#[derive(Debug, Clone)]
pub enum StreamAction {
    /// Forward the token to the stream.
    Forward(String),
    /// Filter (drop) this token.
    Filter,
    /// Replace the token with different content.
    Replace(String),
}

/// Action to take on error.
#[derive(Debug, Clone)]
pub enum ErrorAction {
    /// Propagate the error normally.
    Propagate,
    /// Retry the operation.
    Retry,
    /// Ignore the error and continue.
    Ignore,
}

/// Context for a completion (LLM call) hook.
#[derive(Debug, Clone)]
pub struct CompletionContext {
    /// The messages being sent to the LLM.
    pub messages: Vec<Message>,
    /// The model being used.
    pub model: String,
    /// Additional metadata.
    pub metadata: HashMap<String, Value>,
}

/// Result context after a completion (LLM call).
#[derive(Debug, Clone)]
pub struct CompletionResult {
    /// The response message from the LLM.
    pub message: Message,
    /// Token usage if available.
    pub tokens_used: Option<lc_core::language_models::TokenUsage>,
}

/// Context for a tool call hook.
#[derive(Debug, Clone)]
pub struct ToolCallContext {
    /// The tool name.
    pub name: String,
    /// The tool arguments.
    pub arguments: Value,
    /// The tool call ID (for function calling style).
    pub tool_id: String,
}

/// Result context after a tool call.
#[derive(Debug, Clone)]
pub struct ToolResultContext {
    /// The tool name.
    pub name: String,
    /// The tool result.
    pub result: String,
    /// The tool call ID.
    pub tool_id: String,
}

/// Trait for agent lifecycle hooks.
///
/// Implement this trait to inject custom behavior at key points in the
/// agent execution loop. All methods have default no-op implementations,
/// so you only need to override the ones you care about.
#[async_trait]
pub trait AgentHook: Send + Sync {
    /// Called before an LLM completion. Can modify messages or reject the call.
    fn on_before_completion(&self, _ctx: &mut CompletionContext) -> CompletionAction {
        CompletionAction::Continue
    }

    /// Called after an LLM completion. Can modify the response.
    fn on_after_completion(&self, _ctx: &mut CompletionResult) -> Result<(), HookError> {
        Ok(())
    }

    /// Called before a tool call. Can approve, reject, modify, or skip.
    fn on_before_tool_call(&self, _ctx: &mut ToolCallContext) -> ToolCallAction {
        ToolCallAction::Continue
    }

    /// Called after a tool call. Can modify the result.
    fn on_after_tool_call(&self, _ctx: &mut ToolResultContext) -> Result<(), HookError> {
        Ok(())
    }

    /// Called for each streaming token. Can filter, replace, or forward.
    fn on_stream_chunk(&self, chunk: &str) -> StreamAction {
        StreamAction::Forward(chunk.to_string())
    }

    /// Called when the agent starts execution.
    fn on_agent_start(&self, _input: &str) -> Result<(), HookError> {
        Ok(())
    }

    /// Called when the agent finishes execution.
    fn on_agent_end(&self, _output: &str) -> Result<(), HookError> {
        Ok(())
    }

    /// Called when an error occurs. Can retry, ignore, or propagate.
    fn on_error(&self, _error: &HookError) -> ErrorAction {
        ErrorAction::Propagate
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_completion_action_default_continue() {
        let action = CompletionAction::Continue;
        assert!(matches!(action, CompletionAction::Continue));
    }

    #[test]
    fn test_tool_call_action_variants() {
        let continue_action = ToolCallAction::Continue;
        let modify_action = ToolCallAction::Modify {
            name: "calc".to_string(),
            arguments: serde_json::json!({"x": 1}),
        };
        let reject_action = ToolCallAction::Reject {
            reason: "not allowed".to_string(),
        };
        let skip_action = ToolCallAction::Skip;

        assert!(matches!(continue_action, ToolCallAction::Continue));
        assert!(matches!(modify_action, ToolCallAction::Modify { .. }));
        assert!(matches!(reject_action, ToolCallAction::Reject { .. }));
        assert!(matches!(skip_action, ToolCallAction::Skip));
    }

    #[test]
    fn test_stream_action_variants() {
        let forward = StreamAction::Forward("hello".to_string());
        let filter = StreamAction::Filter;
        let replace = StreamAction::Replace("[REDACTED]".to_string());

        assert!(matches!(forward, StreamAction::Forward(_)));
        assert!(matches!(filter, StreamAction::Filter));
        assert!(matches!(replace, StreamAction::Replace(_)));
    }

    #[test]
    fn test_error_action_variants() {
        assert!(matches!(ErrorAction::Propagate, ErrorAction::Propagate));
        assert!(matches!(ErrorAction::Retry, ErrorAction::Retry));
        assert!(matches!(ErrorAction::Ignore, ErrorAction::Ignore));
    }

    #[test]
    fn test_hook_error_display() {
        let rejected = HookError::Rejected("not allowed".to_string());
        assert_eq!(format!("{}", rejected), "Hook rejected: not allowed");

        let other = HookError::Other("something broke".to_string());
        assert_eq!(format!("{}", other), "Hook error: something broke");
    }

    #[test]
    fn test_completion_context_default() {
        let ctx = CompletionContext {
            messages: vec![],
            model: "gpt-4".to_string(),
            metadata: HashMap::new(),
        };
        assert_eq!(ctx.model, "gpt-4");
        assert!(ctx.messages.is_empty());
    }

    #[test]
    fn test_tool_call_context() {
        let ctx = ToolCallContext {
            name: "calculator".to_string(),
            arguments: serde_json::json!({"expr": "2+2"}),
            tool_id: "call_123".to_string(),
        };
        assert_eq!(ctx.name, "calculator");
        assert_eq!(ctx.tool_id, "call_123");
    }

    #[test]
    fn test_tool_result_context() {
        let ctx = ToolResultContext {
            name: "calculator".to_string(),
            result: "4".to_string(),
            tool_id: "call_123".to_string(),
        };
        assert_eq!(ctx.result, "4");
    }

    #[test]
    fn test_completion_result() {
        let result = CompletionResult {
            message: lc_schema::Message::ai("Hello!"),
            tokens_used: None,
        };
        assert_eq!(result.message.content, "Hello!");
    }

    /// A custom hook that tracks all hook calls for testing.
    struct TrackingHook {
        before_completion_called: std::sync::atomic::AtomicBool,
        after_completion_called: std::sync::atomic::AtomicBool,
        before_tool_called: std::sync::atomic::AtomicBool,
        after_tool_called: std::sync::atomic::AtomicBool,
        agent_start_called: std::sync::atomic::AtomicBool,
        agent_end_called: std::sync::atomic::AtomicBool,
        error_called: std::sync::atomic::AtomicBool,
    }

    impl TrackingHook {
        fn new() -> Self {
            Self {
                before_completion_called: std::sync::atomic::AtomicBool::new(false),
                after_completion_called: std::sync::atomic::AtomicBool::new(false),
                before_tool_called: std::sync::atomic::AtomicBool::new(false),
                after_tool_called: std::sync::atomic::AtomicBool::new(false),
                agent_start_called: std::sync::atomic::AtomicBool::new(false),
                agent_end_called: std::sync::atomic::AtomicBool::new(false),
                error_called: std::sync::atomic::AtomicBool::new(false),
            }
        }
    }

    #[async_trait]
    impl AgentHook for TrackingHook {
        fn on_before_completion(&self, _ctx: &mut CompletionContext) -> CompletionAction {
            self.before_completion_called
                .store(true, std::sync::atomic::Ordering::SeqCst);
            CompletionAction::Continue
        }

        fn on_after_completion(&self, _ctx: &mut CompletionResult) -> Result<(), HookError> {
            self.after_completion_called
                .store(true, std::sync::atomic::Ordering::SeqCst);
            Ok(())
        }

        fn on_before_tool_call(&self, _ctx: &mut ToolCallContext) -> ToolCallAction {
            self.before_tool_called
                .store(true, std::sync::atomic::Ordering::SeqCst);
            ToolCallAction::Continue
        }

        fn on_after_tool_call(&self, _ctx: &mut ToolResultContext) -> Result<(), HookError> {
            self.after_tool_called
                .store(true, std::sync::atomic::Ordering::SeqCst);
            Ok(())
        }

        fn on_agent_start(&self, _input: &str) -> Result<(), HookError> {
            self.agent_start_called
                .store(true, std::sync::atomic::Ordering::SeqCst);
            Ok(())
        }

        fn on_agent_end(&self, _output: &str) -> Result<(), HookError> {
            self.agent_end_called
                .store(true, std::sync::atomic::Ordering::SeqCst);
            Ok(())
        }

        fn on_error(&self, _error: &HookError) -> ErrorAction {
            self.error_called
                .store(true, std::sync::atomic::Ordering::SeqCst);
            ErrorAction::Propagate
        }
    }

    #[test]
    fn test_custom_hook_tracking() {
        let hook = TrackingHook::new();

        // Simulate hook calls
        let mut ctx = CompletionContext {
            messages: vec![],
            model: "gpt-4".to_string(),
            metadata: HashMap::new(),
        };
        hook.on_before_completion(&mut ctx);
        assert!(hook
            .before_completion_called
            .load(std::sync::atomic::Ordering::SeqCst));

        hook.on_agent_start("test input").unwrap();
        assert!(hook
            .agent_start_called
            .load(std::sync::atomic::Ordering::SeqCst));

        hook.on_agent_end("test output").unwrap();
        assert!(hook
            .agent_end_called
            .load(std::sync::atomic::Ordering::SeqCst));
    }

    #[test]
    fn test_completion_action_reject() {
        let action = CompletionAction::Reject {
            reason: "blocked".to_string(),
        };
        if let CompletionAction::Reject { reason } = action {
            assert_eq!(reason, "blocked");
        } else {
            panic!("Expected Reject");
        }
    }

    #[test]
    fn test_completion_action_modify() {
        let action = CompletionAction::Modify {
            messages: vec![lc_schema::Message::system("test")],
        };
        if let CompletionAction::Modify { messages } = action {
            assert_eq!(messages.len(), 1);
        } else {
            panic!("Expected Modify");
        }
    }
}