lellm-agent 0.4.9

Agent Runtime for LeLLM — ToolUseLoop, Executor, Fallback
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
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
//! ToolUseLoop 配置与请求构建辅助。
//!
//! - `ToolUseConfig` — 纯参数,Clone + Send + Sync
//! - `ToolUseDeps` — 策略服务,Arc 包裹
//! - `build_request_*` — 请求构建辅助函数

use lellm_core::{CacheControl, ChatRequest, LlmError, Message, Prompt, ToolDefinition};
use lellm_provider::ResolvedModel;

use super::context::ContextBudget;
use super::fallback::FallbackStrategy;
use super::request_opts::RequestOptions;
use super::retry::RetryPolicy;
use std::sync::Arc;

// ─── 配置(纯参数)──────────────────────────────────────────────

/// ToolUseLoop 纯参数配置。
///
/// - `Clone` + `Send` + `Sync` — 可安全跨线程共享
/// - 仅包含数据字段,不含行为逻辑
/// - 未来可扩展为 `Serialize` / `Deserialize`
#[derive(Debug, Clone)]
pub struct ToolUseConfig {
    /// 系统提示(运行时注入,不修改 messages)。
    ///
    /// 支持 `Prompt` 类型,统一了简单文本与分层缓存两种模式。
    /// - 简单文本:通过 `From<String>` 自动转换
    /// - 分层缓存:`Prompt::new().stable(...).build()`
    pub system: Option<Prompt>,
    /// 最大迭代轮次(默认 10)
    pub max_iterations: usize,
    /// 每次 LLM 请求的最大输出 token 数(默认 4k)
    ///
    /// 控制单次 Provider 调用的响应长度上限,防止模型输出过长。
    /// 工具调用轮次通常只需几百 token,但模型 thinking 会消耗额外空间,
    /// 4k 在"够用"和"不浪费"之间取得平衡。
    /// 若需要长文本生成,可通过 Builder 调大。
    /// 会自动注入到 `ChatRequest.max_tokens`。
    pub max_output_tokens: u32,
    /// 整个 Agent Run 的最大输出 token 总数(可选,默认无限制)。
    ///
    /// 即使每轮的 `max_output_tokens` 设置合理,多轮工具调用仍可能导致
    /// 总输出巨大(如 10 轮 × 4k = 40k)。此字段提供聚合层面的保险丝,
    /// 防止因工具循环或 Provider 忽略 max_tokens 而导致的成本失控。
    ///
    /// 统计范围:Assistant Text(不含 Thinking,不含 Tool Call 结构开销)。
    /// 在流式模式下边接收边检查,达到阈值立即停止。
    pub max_total_output_tokens: Option<u32>,
    /// 整个 Agent Run 的最大推理 token 总数(可选,默认无限制)。
    ///
    /// 与 `max_total_output_tokens` 分离:thinking 是模型内部推理,不计入输出预算。
    /// 双层设计:
    /// - 单轮:`RequestOptions.max_reasoning_tokens` → 透传给 Provider
    /// - 总计:`max_total_reasoning_tokens` → Agent 层累计检查
    pub max_total_reasoning_tokens: Option<u32>,
    /// 上下文预算管理(默认开启)
    ///
    /// **v0.1**: 默认 `ContextBudget::default()`(max_tokens = 128,000)
    /// **v0.2**: 从 `ResolvedModel.context_window` 自动推导(window * 0.8)
    ///
    /// 若要关闭限制,设置 `max_tokens = usize::MAX`。
    pub context_budget: ContextBudget,
    /// 每轮 LLM 调用的生成参数覆盖。
    ///
    /// 独立字段定义,不与 `ChatRequest` 耦合。
    /// `apply()` 方法将非默认值(temperature、top_p、reasoning 等)
    /// 覆盖到 Agent 层构建的基础 `ChatRequest` 上。
    ///
    /// `model`、`messages`、`tools` 由 Agent 层注入,不会被覆盖。
    pub request_options: RequestOptions,
    /// 是否向消费者流式输出推理过程(ThinkingDelta 事件)。
    ///
    /// `false`(默认)= 模型可推理,但不向消费者发射 ThinkingDelta 事件
    /// `true` = 将推理内容以 ThinkingDelta 事件流式输出
    ///
    /// **重要:** 此字段控制框架行为(Event 管道),不属于协议参数。
    /// 不应出现在 `ChatRequest` 中(Codec 不应看到此字段)。
    pub stream_thinking: bool,
    /// 工具缓存策略(默认 `Auto`)。
    ///
    /// 控制框架如何为 Tool Definitions 添加 `cache_control` 标记:
    /// - `Auto`(默认):为未设置 `cache_control` 的工具自动添加 `Breakpoint`
    /// - `Preserve`:不修改用户设置的 `cache_control`
    /// - `Disabled`:显式清除所有工具的 `cache_control`
    pub tool_cache_policy: ToolCachePolicy,
    /// 工具重试策略
    pub retry_policy: RetryPolicy,
}

/// 工具缓存策略。
///
/// 控制框架如何为 Tool Definitions 补充 `cache_control` 标记,
/// 以最大化前缀缓存命中率。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ToolCachePolicy {
    /// 自动模式(默认):为未设置 `cache_control` 的工具添加 `Breakpoint`。
    /// 已显式设置的标记不会被覆盖。
    #[default]
    Auto,
    /// 保留模式:不修改用户设置的 `cache_control`。
    Preserve,
    /// 禁用模式:清除所有工具的 `cache_control`。
    Disabled,
}

impl Default for ToolUseConfig {
    fn default() -> Self {
        Self {
            system: None,
            max_iterations: 10,
            max_output_tokens: 4_000,
            max_total_output_tokens: None,
            max_total_reasoning_tokens: None,
            context_budget: ContextBudget::default(),
            request_options: RequestOptions::default(),
            stream_thinking: false,
            tool_cache_policy: ToolCachePolicy::default(),
            retry_policy: RetryPolicy::default(),
        }
    }
}

// ─── 依赖(策略服务)────────────────────────────────────────────

/// ToolUseLoop 策略依赖。
///
/// 包含有行为逻辑的服务对象(Arc 包裹),与纯参数 Config 分离。
#[derive(Clone)]
pub struct ToolUseDeps {
    /// Provider 降级策略
    pub fallback: Arc<dyn FallbackStrategy>,
}

impl Default for ToolUseDeps {
    fn default() -> Self {
        Self {
            fallback: Arc::new(super::fallback::DefaultFallback::default()),
        }
    }
}

// ─── 辅助函数 ───────────────────────────────────────────────────

/// 检查消息列表中是否已存在 System 消息。
pub(super) fn has_system_message(messages: &[Message]) -> bool {
    messages.iter().any(|m| matches!(m, Message::System { .. }))
}

/// 构建有效的请求消息列表(用于 spawned task,无法使用 &self)
pub(super) fn build_request_messages_inner(
    config: &ToolUseConfig,
    messages: &[Message],
) -> Result<Vec<Message>, LlmError> {
    if let Some(ref system) = config.system {
        if has_system_message(messages) {
            return Err(LlmError::DuplicateSystemPrompt);
        }
        let mut result = vec![system.clone().build()];
        result.extend(messages.iter().cloned());
        Ok(result)
    } else {
        Ok(messages.to_vec())
    }
}

/// 构建 ChatRequest(用于 spawned task)
///
/// 先构建基础请求(Agent 层注入 model/messages/tools/max_tokens),
/// 再应用 RequestOptions 非默认值覆盖。
///
/// `definitions` — 预解析的工具定义列表(从 ResolvedRound 获取)。
/// `tool_cache_policy` — 工具缓存策略,控制如何为 tools 添加 cache_control。
pub(super) fn build_request_inner(
    model: &ResolvedModel,
    messages: &[Message],
    max_output_tokens: u32,
    request_options: &RequestOptions,
    definitions: &[ToolDefinition],
    tool_cache_policy: ToolCachePolicy,
) -> ChatRequest {
    let tools = match tool_cache_policy {
        ToolCachePolicy::Auto => {
            if definitions.is_empty() {
                None
            } else {
                // 只在最后一个 tool 上放置 Breakpoint(前缀缓存是累积的,
                // 中间断点不产生独立缓存段,浪费 Anthropic 4 断点/请求的限额)。
                // 与 System prompt 的断点策略保持一致。
                let last_idx = definitions.len() - 1;
                Some(
                    definitions
                        .iter()
                        .enumerate()
                        .map(|(i, d)| {
                            if d.cache_control.is_some() {
                                d.clone()
                            } else if i == last_idx {
                                let mut cloned = d.clone();
                                cloned.cache_control = Some(CacheControl::Breakpoint);
                                cloned
                            } else {
                                d.clone()
                            }
                        })
                        .collect(),
                )
            }
        }
        ToolCachePolicy::Preserve => {
            if definitions.is_empty() {
                None
            } else {
                Some(definitions.to_vec())
            }
        }
        ToolCachePolicy::Disabled => {
            if definitions.is_empty() {
                None
            } else {
                Some(
                    definitions
                        .iter()
                        .map(|d| {
                            let mut cloned = d.clone();
                            cloned.cache_control = None;
                            cloned
                        })
                        .collect(),
                )
            }
        }
    };

    let mut req = ChatRequest {
        model: model.model.clone(),
        messages: messages.to_vec(),
        tools,
        max_tokens: Some(max_output_tokens),
        temperature: None,
        top_p: None,
        seed: None,
        tool_choice: None,
        stop_sequences: None,
        prefill: None,
        reasoning: None,
        max_reasoning_tokens: None,
        extra: None,
    };

    // 应用 RequestOptions 非默认值覆盖
    request_options.apply(&mut req);

    req
}

/// 构建首轮 ChatRequest,支持强制指定工具(仅第一轮生效)。
///
/// 当 `RequestOptions` 设置了 `tool_choice` 时,仅在第一轮注入;
/// 后续轮次由 LLM 自主决定是否调用工具。
///
/// `definitions` — 预解析的工具定义列表(从 ResolvedRound 获取)。
/// `tool_cache_policy` — 工具缓存策略,控制如何为 tools 添加 cache_control。
pub(super) fn build_request_inner_with_round(
    model: &ResolvedModel,
    messages: &[Message],
    max_output_tokens: u32,
    request_options: &RequestOptions,
    iteration: usize,
    definitions: &[ToolDefinition],
    tool_cache_policy: ToolCachePolicy,
) -> ChatRequest {
    let mut req = build_request_inner(
        model,
        messages,
        max_output_tokens,
        request_options,
        definitions,
        tool_cache_policy,
    );

    // 如果 RequestOptions 设置了 tool_choice 且不是第一轮,清除它
    // 让 LLM 在工具调用后自主选择
    if iteration > 0 && request_options.tool_choice.is_some() {
        req.tool_choice = None;
    }

    req
}

/// 构建空的 ChatResponse(边界情况兜底)
pub(super) fn empty_response() -> lellm_core::ChatResponse {
    lellm_core::ChatResponse::new(
        lellm_core::text_block(String::new()),
        lellm_core::TokenUsage::default(),
        serde_json::Value::Null,
    )
}

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

    #[test]
    fn test_build_request_messages_with_prompt() {
        let config = ToolUseConfig {
            system: Some(
                Prompt::new()
                    .stable("核心身份")
                    .stable("工具指南")
                    .dynamic("会话上下文"),
            ),
            ..Default::default()
        };

        let messages: Vec<Message> = vec![Message::user_text("你好")];
        let result = build_request_messages_inner(&config, &messages).unwrap();

        // Should have system + user
        assert_eq!(result.len(), 2);

        // Verify system message has 3 content blocks with correct cache markers.
        // Only the LAST cached layer gets the breakpoint (Anthropic max 4 per request).
        if let Message::System { content } = &result[0] {
            assert_eq!(content.len(), 3);

            // Layer 1 — cached, but NO breakpoint (not the last cached)
            if let lellm_core::ContentBlock::Text(t) = &content[0] {
                assert_eq!(t.text, "核心身份");
                assert!(
                    t.cache_control.is_none(),
                    "Intermediate cached layer should NOT have breakpoint"
                );
            } else {
                panic!("expected Text block");
            }

            // Layer 2 — cached, HAS breakpoint (last cached before dynamic)
            if let lellm_core::ContentBlock::Text(t) = &content[1] {
                assert_eq!(t.text, "工具指南");
                assert!(
                    t.cache_control.is_some(),
                    "Last cached layer should have breakpoint"
                );
            } else {
                panic!("expected Text block");
            }

            // Layer 3 — dynamic (no cache)
            if let lellm_core::ContentBlock::Text(t) = &content[2] {
                assert_eq!(t.text, "会话上下文");
                assert!(t.cache_control.is_none());
            } else {
                panic!("expected Text block");
            }
        } else {
            panic!("expected System message");
        }
    }

    #[test]
    fn test_build_request_messages_with_plain_prompt() {
        let config = ToolUseConfig {
            system: Some("简单提示".into()),
            ..Default::default()
        };

        let messages: Vec<Message> = vec![Message::user_text("你好")];
        let result = build_request_messages_inner(&config, &messages).unwrap();

        assert_eq!(result.len(), 2);
        if let Message::System { content } = &result[0] {
            assert_eq!(content.len(), 1);
            assert_eq!(content[0].as_text(), Some("简单提示"));
        } else {
            panic!("expected System message");
        }
    }

    #[test]
    fn test_build_request_messages_no_system() {
        let config = ToolUseConfig::default();
        let messages: Vec<Message> = vec![Message::user_text("你好")];
        let result = build_request_messages_inner(&config, &messages).unwrap();

        assert_eq!(result.len(), 1);
        assert!(matches!(result[0], Message::User { .. }));
    }

    #[test]
    fn test_duplicate_system_prompt_error() {
        let config = ToolUseConfig {
            system: Some("系统提示".into()),
            ..Default::default()
        };

        let messages: Vec<Message> =
            vec![Message::system_text("已有系统"), Message::user_text("你好")];
        let result = build_request_messages_inner(&config, &messages);

        assert!(result.is_err());
    }

    #[test]
    fn test_tool_cache_auto_only_last_breakpoint() {
        let definitions: Vec<ToolDefinition> = vec![
            ToolDefinition {
                name: "search".into(),
                description: "Search".into(),
                parameters: serde_json::json!({}),
                cache_control: None,
            },
            ToolDefinition {
                name: "calculate".into(),
                description: "Calculate".into(),
                parameters: serde_json::json!({}),
                cache_control: None,
            },
            ToolDefinition {
                name: "save".into(),
                description: "Save".into(),
                parameters: serde_json::json!({}),
                cache_control: None,
            },
        ];

        let req = build_request_inner(
            &ResolvedModel {
                provider: Arc::new(lellm_provider::providers::mock::MockProvider::new(vec![])),
                model: "test".into(),
                context_window: Some(100_000),
            },
            &[],
            4096,
            &RequestOptions::default(),
            &definitions,
            ToolCachePolicy::Auto,
        );

        let tools = req.tools.unwrap();
        assert_eq!(tools.len(), 3);

        // First two tools should NOT have breakpoint
        assert!(
            tools[0].cache_control.is_none(),
            "First tool should NOT have breakpoint"
        );
        assert!(
            tools[1].cache_control.is_none(),
            "Second tool should NOT have breakpoint"
        );
        // Last tool SHOULD have breakpoint
        assert!(
            tools[2].cache_control == Some(CacheControl::Breakpoint),
            "Last tool should have breakpoint"
        );
    }

    #[test]
    fn test_tool_cache_auto_preserves_explicit() {
        let definitions: Vec<ToolDefinition> = vec![
            ToolDefinition {
                name: "search".into(),
                description: "Search".into(),
                parameters: serde_json::json!({}),
                cache_control: None,
            },
            ToolDefinition {
                name: "calculate".into(),
                description: "Calculate".into(),
                parameters: serde_json::json!({}),
                // User explicitly set cache_control
                cache_control: Some(CacheControl::Breakpoint),
            },
        ];

        let req = build_request_inner(
            &ResolvedModel {
                provider: Arc::new(lellm_provider::providers::mock::MockProvider::new(vec![])),
                model: "test".into(),
                context_window: Some(100_000),
            },
            &[],
            4096,
            &RequestOptions::default(),
            &definitions,
            ToolCachePolicy::Auto,
        );

        let tools = req.tools.unwrap();
        // First tool - no explicit, not last -> no breakpoint
        assert!(tools[0].cache_control.is_none());
        // Second tool - explicit Breakpoint preserved (also last, so would get one anyway)
        assert!(tools[1].cache_control == Some(CacheControl::Breakpoint));
    }
}