Skip to main content

lellm_agent/runtime/
config.rs

1//! ToolUseLoop 配置与请求构建辅助。
2//!
3//! - `ToolUseConfig` — 纯参数,Clone + Send + Sync
4//! - `ToolUseDeps` — 策略服务,Arc 包裹
5//! - `build_request_*` — 请求构建辅助函数
6
7use lellm_core::{CacheControl, ChatRequest, LlmError, Message, Prompt, ToolDefinition};
8use lellm_provider::ResolvedModel;
9
10use super::context::ContextBudget;
11use super::fallback::FallbackStrategy;
12use super::request_opts::RequestOptions;
13use super::retry::RetryPolicy;
14use std::sync::Arc;
15
16// ─── 配置(纯参数)──────────────────────────────────────────────
17
18/// ToolUseLoop 纯参数配置。
19///
20/// - `Clone` + `Send` + `Sync` — 可安全跨线程共享
21/// - 仅包含数据字段,不含行为逻辑
22/// - 未来可扩展为 `Serialize` / `Deserialize`
23#[derive(Debug, Clone)]
24pub struct ToolUseConfig {
25    /// 系统提示(运行时注入,不修改 messages)。
26    ///
27    /// 支持 `Prompt` 类型,统一了简单文本与分层缓存两种模式。
28    /// - 简单文本:通过 `From<String>` 自动转换
29    /// - 分层缓存:`Prompt::new().stable(...).build()`
30    pub system: Option<Prompt>,
31    /// 最大迭代轮次(默认 10)
32    pub max_iterations: usize,
33    /// 每次 LLM 请求的最大输出 token 数(默认 4k)
34    ///
35    /// 控制单次 Provider 调用的响应长度上限,防止模型输出过长。
36    /// 工具调用轮次通常只需几百 token,但模型 thinking 会消耗额外空间,
37    /// 4k 在"够用"和"不浪费"之间取得平衡。
38    /// 若需要长文本生成,可通过 Builder 调大。
39    /// 会自动注入到 `ChatRequest.max_tokens`。
40    pub max_output_tokens: u32,
41    /// 整个 Agent Run 的最大输出 token 总数(可选,默认无限制)。
42    ///
43    /// 即使每轮的 `max_output_tokens` 设置合理,多轮工具调用仍可能导致
44    /// 总输出巨大(如 10 轮 × 4k = 40k)。此字段提供聚合层面的保险丝,
45    /// 防止因工具循环或 Provider 忽略 max_tokens 而导致的成本失控。
46    ///
47    /// 统计范围:Assistant Text(不含 Thinking,不含 Tool Call 结构开销)。
48    /// 在流式模式下边接收边检查,达到阈值立即停止。
49    pub max_total_output_tokens: Option<u32>,
50    /// 整个 Agent Run 的最大推理 token 总数(可选,默认无限制)。
51    ///
52    /// 与 `max_total_output_tokens` 分离:thinking 是模型内部推理,不计入输出预算。
53    /// 双层设计:
54    /// - 单轮:`RequestOptions.max_reasoning_tokens` → 透传给 Provider
55    /// - 总计:`max_total_reasoning_tokens` → Agent 层累计检查
56    pub max_total_reasoning_tokens: Option<u32>,
57    /// 上下文预算管理(默认开启)
58    ///
59    /// **v0.1**: 默认 `ContextBudget::default()`(max_tokens = 128,000)
60    /// **v0.2**: 从 `ResolvedModel.context_window` 自动推导(window * 0.8)
61    ///
62    /// 若要关闭限制,设置 `max_tokens = usize::MAX`。
63    pub context_budget: ContextBudget,
64    /// 每轮 LLM 调用的生成参数覆盖。
65    ///
66    /// 独立字段定义,不与 `ChatRequest` 耦合。
67    /// `apply()` 方法将非默认值(temperature、top_p、reasoning 等)
68    /// 覆盖到 Agent 层构建的基础 `ChatRequest` 上。
69    ///
70    /// `model`、`messages`、`tools` 由 Agent 层注入,不会被覆盖。
71    pub request_options: RequestOptions,
72    /// 是否向消费者流式输出推理过程(ThinkingDelta 事件)。
73    ///
74    /// `false`(默认)= 模型可推理,但不向消费者发射 ThinkingDelta 事件
75    /// `true` = 将推理内容以 ThinkingDelta 事件流式输出
76    ///
77    /// **重要:** 此字段控制框架行为(Event 管道),不属于协议参数。
78    /// 不应出现在 `ChatRequest` 中(Codec 不应看到此字段)。
79    pub stream_thinking: bool,
80    /// 工具缓存策略(默认 `Auto`)。
81    ///
82    /// 控制框架如何为 Tool Definitions 添加 `cache_control` 标记:
83    /// - `Auto`(默认):为未设置 `cache_control` 的工具自动添加 `Breakpoint`
84    /// - `Preserve`:不修改用户设置的 `cache_control`
85    /// - `Disabled`:显式清除所有工具的 `cache_control`
86    pub tool_cache_policy: ToolCachePolicy,
87    /// 工具重试策略
88    pub retry_policy: RetryPolicy,
89}
90
91/// 工具缓存策略。
92///
93/// 控制框架如何为 Tool Definitions 补充 `cache_control` 标记,
94/// 以最大化前缀缓存命中率。
95#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
96pub enum ToolCachePolicy {
97    /// 自动模式(默认):为未设置 `cache_control` 的工具添加 `Breakpoint`。
98    /// 已显式设置的标记不会被覆盖。
99    #[default]
100    Auto,
101    /// 保留模式:不修改用户设置的 `cache_control`。
102    Preserve,
103    /// 禁用模式:清除所有工具的 `cache_control`。
104    Disabled,
105}
106
107impl Default for ToolUseConfig {
108    fn default() -> Self {
109        Self {
110            system: None,
111            max_iterations: 10,
112            max_output_tokens: 4_000,
113            max_total_output_tokens: None,
114            max_total_reasoning_tokens: None,
115            context_budget: ContextBudget::default(),
116            request_options: RequestOptions::default(),
117            stream_thinking: false,
118            tool_cache_policy: ToolCachePolicy::default(),
119            retry_policy: RetryPolicy::default(),
120        }
121    }
122}
123
124// ─── 依赖(策略服务)────────────────────────────────────────────
125
126/// ToolUseLoop 策略依赖。
127///
128/// 包含有行为逻辑的服务对象(Arc 包裹),与纯参数 Config 分离。
129#[derive(Clone)]
130pub struct ToolUseDeps {
131    /// Provider 降级策略
132    pub fallback: Arc<dyn FallbackStrategy>,
133}
134
135impl Default for ToolUseDeps {
136    fn default() -> Self {
137        Self {
138            fallback: Arc::new(super::fallback::DefaultFallback::default()),
139        }
140    }
141}
142
143// ─── 辅助函数 ───────────────────────────────────────────────────
144
145/// 检查消息列表中是否已存在 System 消息。
146pub(super) fn has_system_message(messages: &[Message]) -> bool {
147    messages.iter().any(|m| matches!(m, Message::System { .. }))
148}
149
150/// 构建有效的请求消息列表(用于 spawned task,无法使用 &self)
151pub(super) fn build_request_messages_inner(
152    config: &ToolUseConfig,
153    messages: &[Message],
154) -> Result<Vec<Message>, LlmError> {
155    if let Some(ref system) = config.system {
156        if has_system_message(messages) {
157            return Err(LlmError::DuplicateSystemPrompt);
158        }
159        let mut result = vec![system.clone().build()];
160        result.extend(messages.iter().cloned());
161        Ok(result)
162    } else {
163        Ok(messages.to_vec())
164    }
165}
166
167/// 构建 ChatRequest(用于 spawned task)
168///
169/// 先构建基础请求(Agent 层注入 model/messages/tools/max_tokens),
170/// 再应用 RequestOptions 非默认值覆盖。
171///
172/// `definitions` — 预解析的工具定义列表(从 ResolvedRound 获取)。
173/// `tool_cache_policy` — 工具缓存策略,控制如何为 tools 添加 cache_control。
174pub(super) fn build_request_inner(
175    model: &ResolvedModel,
176    messages: &[Message],
177    max_output_tokens: u32,
178    request_options: &RequestOptions,
179    definitions: &[ToolDefinition],
180    tool_cache_policy: ToolCachePolicy,
181) -> ChatRequest {
182    let tools = match tool_cache_policy {
183        ToolCachePolicy::Auto => {
184            if definitions.is_empty() {
185                None
186            } else {
187                // 只在最后一个 tool 上放置 Breakpoint(前缀缓存是累积的,
188                // 中间断点不产生独立缓存段,浪费 Anthropic 4 断点/请求的限额)。
189                // 与 System prompt 的断点策略保持一致。
190                let last_idx = definitions.len() - 1;
191                Some(
192                    definitions
193                        .iter()
194                        .enumerate()
195                        .map(|(i, d)| {
196                            if d.cache_control.is_some() {
197                                d.clone()
198                            } else if i == last_idx {
199                                let mut cloned = d.clone();
200                                cloned.cache_control = Some(CacheControl::Breakpoint);
201                                cloned
202                            } else {
203                                d.clone()
204                            }
205                        })
206                        .collect(),
207                )
208            }
209        }
210        ToolCachePolicy::Preserve => {
211            if definitions.is_empty() {
212                None
213            } else {
214                Some(definitions.to_vec())
215            }
216        }
217        ToolCachePolicy::Disabled => {
218            if definitions.is_empty() {
219                None
220            } else {
221                Some(
222                    definitions
223                        .iter()
224                        .map(|d| {
225                            let mut cloned = d.clone();
226                            cloned.cache_control = None;
227                            cloned
228                        })
229                        .collect(),
230                )
231            }
232        }
233    };
234
235    let mut req = ChatRequest {
236        model: model.model.clone(),
237        messages: messages.to_vec(),
238        tools,
239        max_tokens: Some(max_output_tokens),
240        temperature: None,
241        top_p: None,
242        seed: None,
243        tool_choice: None,
244        stop_sequences: None,
245        prefill: None,
246        reasoning: None,
247        max_reasoning_tokens: None,
248        extra: None,
249    };
250
251    // 应用 RequestOptions 非默认值覆盖
252    request_options.apply(&mut req);
253
254    req
255}
256
257/// 构建首轮 ChatRequest,支持强制指定工具(仅第一轮生效)。
258///
259/// 当 `RequestOptions` 设置了 `tool_choice` 时,仅在第一轮注入;
260/// 后续轮次由 LLM 自主决定是否调用工具。
261///
262/// `definitions` — 预解析的工具定义列表(从 ResolvedRound 获取)。
263/// `tool_cache_policy` — 工具缓存策略,控制如何为 tools 添加 cache_control。
264pub(super) fn build_request_inner_with_round(
265    model: &ResolvedModel,
266    messages: &[Message],
267    max_output_tokens: u32,
268    request_options: &RequestOptions,
269    iteration: usize,
270    definitions: &[ToolDefinition],
271    tool_cache_policy: ToolCachePolicy,
272) -> ChatRequest {
273    let mut req = build_request_inner(
274        model,
275        messages,
276        max_output_tokens,
277        request_options,
278        definitions,
279        tool_cache_policy,
280    );
281
282    // 如果 RequestOptions 设置了 tool_choice 且不是第一轮,清除它
283    // 让 LLM 在工具调用后自主选择
284    if iteration > 0 && request_options.tool_choice.is_some() {
285        req.tool_choice = None;
286    }
287
288    req
289}
290
291/// 构建空的 ChatResponse(边界情况兜底)
292pub(super) fn empty_response() -> lellm_core::ChatResponse {
293    lellm_core::ChatResponse::new(
294        lellm_core::text_block(String::new()),
295        lellm_core::TokenUsage::default(),
296        serde_json::Value::Null,
297    )
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use lellm_core::Prompt;
304
305    #[test]
306    fn test_build_request_messages_with_prompt() {
307        let config = ToolUseConfig {
308            system: Some(
309                Prompt::new()
310                    .stable("核心身份")
311                    .stable("工具指南")
312                    .dynamic("会话上下文"),
313            ),
314            ..Default::default()
315        };
316
317        let messages: Vec<Message> = vec![Message::user_text("你好")];
318        let result = build_request_messages_inner(&config, &messages).unwrap();
319
320        // Should have system + user
321        assert_eq!(result.len(), 2);
322
323        // Verify system message has 3 content blocks with correct cache markers.
324        // Only the LAST cached layer gets the breakpoint (Anthropic max 4 per request).
325        if let Message::System { content } = &result[0] {
326            assert_eq!(content.len(), 3);
327
328            // Layer 1 — cached, but NO breakpoint (not the last cached)
329            if let lellm_core::ContentBlock::Text(t) = &content[0] {
330                assert_eq!(t.text, "核心身份");
331                assert!(
332                    t.cache_control.is_none(),
333                    "Intermediate cached layer should NOT have breakpoint"
334                );
335            } else {
336                panic!("expected Text block");
337            }
338
339            // Layer 2 — cached, HAS breakpoint (last cached before dynamic)
340            if let lellm_core::ContentBlock::Text(t) = &content[1] {
341                assert_eq!(t.text, "工具指南");
342                assert!(
343                    t.cache_control.is_some(),
344                    "Last cached layer should have breakpoint"
345                );
346            } else {
347                panic!("expected Text block");
348            }
349
350            // Layer 3 — dynamic (no cache)
351            if let lellm_core::ContentBlock::Text(t) = &content[2] {
352                assert_eq!(t.text, "会话上下文");
353                assert!(t.cache_control.is_none());
354            } else {
355                panic!("expected Text block");
356            }
357        } else {
358            panic!("expected System message");
359        }
360    }
361
362    #[test]
363    fn test_build_request_messages_with_plain_prompt() {
364        let config = ToolUseConfig {
365            system: Some("简单提示".into()),
366            ..Default::default()
367        };
368
369        let messages: Vec<Message> = vec![Message::user_text("你好")];
370        let result = build_request_messages_inner(&config, &messages).unwrap();
371
372        assert_eq!(result.len(), 2);
373        if let Message::System { content } = &result[0] {
374            assert_eq!(content.len(), 1);
375            assert_eq!(content[0].as_text(), Some("简单提示"));
376        } else {
377            panic!("expected System message");
378        }
379    }
380
381    #[test]
382    fn test_build_request_messages_no_system() {
383        let config = ToolUseConfig::default();
384        let messages: Vec<Message> = vec![Message::user_text("你好")];
385        let result = build_request_messages_inner(&config, &messages).unwrap();
386
387        assert_eq!(result.len(), 1);
388        assert!(matches!(result[0], Message::User { .. }));
389    }
390
391    #[test]
392    fn test_duplicate_system_prompt_error() {
393        let config = ToolUseConfig {
394            system: Some("系统提示".into()),
395            ..Default::default()
396        };
397
398        let messages: Vec<Message> =
399            vec![Message::system_text("已有系统"), Message::user_text("你好")];
400        let result = build_request_messages_inner(&config, &messages);
401
402        assert!(result.is_err());
403    }
404
405    #[test]
406    fn test_tool_cache_auto_only_last_breakpoint() {
407        let definitions: Vec<ToolDefinition> = vec![
408            ToolDefinition {
409                name: "search".into(),
410                description: "Search".into(),
411                parameters: serde_json::json!({}),
412                cache_control: None,
413            },
414            ToolDefinition {
415                name: "calculate".into(),
416                description: "Calculate".into(),
417                parameters: serde_json::json!({}),
418                cache_control: None,
419            },
420            ToolDefinition {
421                name: "save".into(),
422                description: "Save".into(),
423                parameters: serde_json::json!({}),
424                cache_control: None,
425            },
426        ];
427
428        let req = build_request_inner(
429            &ResolvedModel {
430                provider: Arc::new(lellm_provider::providers::mock::MockProvider::new(vec![])),
431                model: "test".into(),
432                context_window: Some(100_000),
433            },
434            &[],
435            4096,
436            &RequestOptions::default(),
437            &definitions,
438            ToolCachePolicy::Auto,
439        );
440
441        let tools = req.tools.unwrap();
442        assert_eq!(tools.len(), 3);
443
444        // First two tools should NOT have breakpoint
445        assert!(
446            tools[0].cache_control.is_none(),
447            "First tool should NOT have breakpoint"
448        );
449        assert!(
450            tools[1].cache_control.is_none(),
451            "Second tool should NOT have breakpoint"
452        );
453        // Last tool SHOULD have breakpoint
454        assert!(
455            tools[2].cache_control == Some(CacheControl::Breakpoint),
456            "Last tool should have breakpoint"
457        );
458    }
459
460    #[test]
461    fn test_tool_cache_auto_preserves_explicit() {
462        let definitions: Vec<ToolDefinition> = vec![
463            ToolDefinition {
464                name: "search".into(),
465                description: "Search".into(),
466                parameters: serde_json::json!({}),
467                cache_control: None,
468            },
469            ToolDefinition {
470                name: "calculate".into(),
471                description: "Calculate".into(),
472                parameters: serde_json::json!({}),
473                // User explicitly set cache_control
474                cache_control: Some(CacheControl::Breakpoint),
475            },
476        ];
477
478        let req = build_request_inner(
479            &ResolvedModel {
480                provider: Arc::new(lellm_provider::providers::mock::MockProvider::new(vec![])),
481                model: "test".into(),
482                context_window: Some(100_000),
483            },
484            &[],
485            4096,
486            &RequestOptions::default(),
487            &definitions,
488            ToolCachePolicy::Auto,
489        );
490
491        let tools = req.tools.unwrap();
492        // First tool - no explicit, not last -> no breakpoint
493        assert!(tools[0].cache_control.is_none());
494        // Second tool - explicit Breakpoint preserved (also last, so would get one anyway)
495        assert!(tools[1].cache_control == Some(CacheControl::Breakpoint));
496    }
497}