Skip to main content

lellm_agent/runtime/
stream_translation.rs

1//! ProviderEvent → AgentStreamEvent → StreamChunk 三层转换模型。
2//!
3//! 职责:将 Provider 层的协议事件转换为 Graph 层的数据面事件。
4//! 防止 Provider 语义泄漏到 Graph 层。
5//!
6//! # 架构
7//!
8//! ```text
9//! ProviderEvent      ← Provider 层协议事件(Token, ThinkingDelta, ResponseComplete...)
10//!      ↓ transform()
11//! AgentStreamEvent   ← Agent 层语义事件(TextDelta, ThinkingDelta, Usage, ResponseComplete...)
12//!      ↓ to_chunk()
13//! StreamChunk        ← Graph 层数据面事件(TextDelta, ThinkingDelta, ToolLifecycle...)
14//! ```
15//!
16//! # 设计原则
17//!
18//! - 信息不丢失 — 使用 `TranslationResult` 而非 `Option<StreamChunk>`
19//! - 一对多映射 — 一个 ProviderEvent 可产生多个 AgentStreamEvent
20//! - Graph 不知道 Provider 的内部协议(Usage, RawChunk, FinishReason 等)
21//! - AgentStreamEvent 是封闭、强类型、exhaustive match
22//!
23//! # 演进路径
24//!
25//! v0.4: `translate_provider_event()` → `TranslationResult`
26//! v0.5: `ProviderEvent → AgentStreamEvent → StreamChunk` 三层模型
27
28use lellm_core::TokenUsage;
29use lellm_graph::StreamChunk;
30use lellm_provider::ProviderEvent;
31
32// ─── AgentStreamEvent ──────────────────────────────────────────
33
34/// Agent 层流式事件 — 语义中间层。
35///
36/// 桥接 Provider 协议事件与 Graph 数据面事件。
37/// 封闭、强类型、exhaustive match。
38#[derive(Debug, Clone)]
39pub enum AgentStreamEvent {
40    /// 文本增量
41    TextDelta { text: String },
42    /// 思考增量(thinking + 可选的 redacted)
43    ThinkingDelta {
44        thinking: String,
45        redacted: Option<String>,
46    },
47    /// 工具调用结果(由 ToolNode 产生,不来自 Provider)
48    ToolResult {
49        call_id: String,
50        tool_name: String,
51        content: String,
52        is_error: bool,
53    },
54    /// Token 使用统计
55    Usage(TokenUsage),
56    /// 单次 LLM 响应完成
57    ResponseComplete,
58}
59
60impl AgentStreamEvent {
61    /// 转换为 Graph 数据面事件。
62    ///
63    /// 并非所有 AgentStreamEvent 都有对应的 StreamChunk:
64    /// - TextDelta → TextDelta
65    /// - ThinkingDelta → ThinkingDelta
66    /// - Usage / ResponseComplete → None(控制面信息,不发射到数据面)
67    /// - ToolResult → None(由 ToolNode 转换为 ToolLifecycle / ToolOutput)
68    pub fn to_chunk(&self) -> Option<StreamChunk> {
69        match self {
70            AgentStreamEvent::TextDelta { text } => Some(StreamChunk::TextDelta(text.clone())),
71            AgentStreamEvent::ThinkingDelta { thinking, redacted } => {
72                Some(StreamChunk::ThinkingDelta {
73                    text: thinking.clone(),
74                    redacted: redacted.clone(),
75                })
76            }
77            AgentStreamEvent::ToolResult { .. } => None,
78            AgentStreamEvent::Usage(_) => None,
79            AgentStreamEvent::ResponseComplete => None,
80        }
81    }
82}
83
84// ─── transform: ProviderEvent → AgentStreamEvent ───────────────
85
86/// 将 Provider 协议事件转换为 Agent 语义事件。
87///
88/// 支持 1:N 映射(一个 ProviderEvent 可能产生多个 AgentStreamEvent)。
89/// 例如 `ResponseComplete` 同时携带 tool_calls 和 usage。
90pub fn transform(event: &ProviderEvent) -> Vec<AgentStreamEvent> {
91    match event {
92        ProviderEvent::Token { token } => {
93            vec![AgentStreamEvent::TextDelta {
94                text: token.clone(),
95            }]
96        }
97        ProviderEvent::ThinkingDelta { thinking, redacted } => {
98            vec![AgentStreamEvent::ThinkingDelta {
99                thinking: thinking.clone(),
100                redacted: redacted.clone(),
101            }]
102        }
103        ProviderEvent::ResponseComplete { tool_calls, usage } => {
104            let mut events = Vec::new();
105            // tool_calls 不在此处转换为 AgentStreamEvent,
106            // 由 LLMNode 直接从 ProviderEvent 中提取。
107            let _ = tool_calls; // 目前不转换,供未来扩展
108            if let Some(u) = usage {
109                events.push(AgentStreamEvent::Usage(u.clone()));
110            }
111            events.push(AgentStreamEvent::ResponseComplete);
112            events
113        }
114        ProviderEvent::Start { .. } => vec![], // 协议事件,忽略
115    }
116}
117
118// ─── 向后兼容:TranslationResult ────────────────────────────────
119
120/// ProviderEvent 转换结果(v0.4 API,向后兼容)。
121///
122/// 使用 enum 而非 `Option<StreamChunk>`,避免静默丢失事件信息。
123/// 每个变体表达一种明确的意图:发射、发射+累积、记录 Usage、标记完成、忽略。
124#[derive(Debug)]
125pub enum TranslationResult {
126    /// 发射一个数据面事件到 StreamSink
127    Emit(StreamChunk),
128    /// 发射 TextDelta 并携带增量文本(供调用方累积构建 ContentBlock)
129    EmitWithText { chunk: StreamChunk, delta: String },
130    /// 发射 ThinkingDelta 并携带增量思考内容(供调用方累积构建 ContentBlock)
131    EmitWithThinking {
132        chunk: StreamChunk,
133        delta: String,
134        redacted: Option<String>,
135    },
136    /// 记录 Usage 信息(不发射 StreamChunk,由 LLMNode 收集)
137    Usage(TokenUsage),
138    /// 响应完成标记
139    Finished,
140    /// 忽略此事件(如 HeadersReceived, Start 等协议事件)
141    Ignore,
142}
143
144/// 将 ProviderEvent 转换为 TranslationResult(v0.4 API,向后兼容)。
145///
146/// 内部通过 `transform()` + `to_chunk()` 实现。
147/// 保留此接口以兼容现有 LLMNode 代码。
148pub fn translate_provider_event(event: &ProviderEvent) -> TranslationResult {
149    // 使用新的三层模型
150    for agent_event in transform(event) {
151        match agent_event {
152            AgentStreamEvent::TextDelta { text } => {
153                return TranslationResult::EmitWithText {
154                    chunk: StreamChunk::TextDelta(text.clone()),
155                    delta: text,
156                };
157            }
158            AgentStreamEvent::ThinkingDelta { thinking, redacted } => {
159                return TranslationResult::EmitWithThinking {
160                    chunk: StreamChunk::ThinkingDelta {
161                        text: thinking.clone(),
162                        redacted: redacted.clone(),
163                    },
164                    delta: thinking,
165                    redacted,
166                };
167            }
168            AgentStreamEvent::Usage(u) => return TranslationResult::Usage(u),
169            AgentStreamEvent::ResponseComplete => return TranslationResult::Finished,
170            AgentStreamEvent::ToolResult { .. } => {}
171        }
172    }
173    TranslationResult::Ignore
174}
175
176// ─── 测试 ──────────────────────────────────────────────────────
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    // ── transform 测试 ──
183
184    #[test]
185    fn test_transform_token() {
186        let event = ProviderEvent::Token {
187            token: "hello".into(),
188        };
189        let result = transform(&event);
190        assert_eq!(result.len(), 1);
191        match &result[0] {
192            AgentStreamEvent::TextDelta { text } => assert_eq!(text, "hello"),
193            other => panic!("expected TextDelta, got {:?}", other),
194        }
195    }
196
197    #[test]
198    fn test_transform_thinking_with_redacted() {
199        let event = ProviderEvent::ThinkingDelta {
200            thinking: "visible".into(),
201            redacted: Some("sensitive".into()),
202        };
203        let result = transform(&event);
204        assert_eq!(result.len(), 1);
205        match &result[0] {
206            AgentStreamEvent::ThinkingDelta { thinking, redacted } => {
207                assert_eq!(thinking, "visible");
208                assert_eq!(redacted, &Some("sensitive".into()));
209            }
210            other => panic!("expected ThinkingDelta, got {:?}", other),
211        }
212    }
213
214    #[test]
215    fn test_transform_thinking_without_redacted() {
216        let event = ProviderEvent::ThinkingDelta {
217            thinking: "thinking".into(),
218            redacted: None,
219        };
220        let result = transform(&event);
221        assert_eq!(result.len(), 1);
222        match &result[0] {
223            AgentStreamEvent::ThinkingDelta { thinking, redacted } => {
224                assert_eq!(thinking, "thinking");
225                assert!(redacted.is_none());
226            }
227            other => panic!("expected ThinkingDelta, got {:?}", other),
228        }
229    }
230
231    #[test]
232    fn test_transform_response_complete_with_usage() {
233        let event = ProviderEvent::ResponseComplete {
234            tool_calls: vec![],
235            usage: Some(TokenUsage::default()),
236        };
237        let result = transform(&event);
238        assert_eq!(result.len(), 2);
239        assert!(matches!(result[0], AgentStreamEvent::Usage(_)));
240        assert!(matches!(result[1], AgentStreamEvent::ResponseComplete));
241    }
242
243    #[test]
244    fn test_transform_response_complete_without_usage() {
245        let event = ProviderEvent::ResponseComplete {
246            tool_calls: vec![],
247            usage: None,
248        };
249        let result = transform(&event);
250        assert_eq!(result.len(), 1);
251        assert!(matches!(result[0], AgentStreamEvent::ResponseComplete));
252    }
253
254    #[test]
255    fn test_transform_start_ignored() {
256        let event = ProviderEvent::Start {
257            model: "test".into(),
258        };
259        let result = transform(&event);
260        assert!(result.is_empty());
261    }
262
263    // ── to_chunk 测试 ──
264
265    #[test]
266    fn test_to_chunk_text_delta() {
267        let event = AgentStreamEvent::TextDelta {
268            text: "hello".into(),
269        };
270        let chunk = event.to_chunk();
271        assert!(chunk.is_some());
272        match chunk.unwrap() {
273            StreamChunk::TextDelta(t) => assert_eq!(t, "hello"),
274            other => panic!("expected TextDelta, got {:?}", other),
275        }
276    }
277
278    #[test]
279    fn test_to_chunk_thinking_delta() {
280        let event = AgentStreamEvent::ThinkingDelta {
281            thinking: "think".into(),
282            redacted: Some("secret".into()),
283        };
284        let chunk = event.to_chunk();
285        assert!(chunk.is_some());
286        match chunk.unwrap() {
287            StreamChunk::ThinkingDelta { text, redacted } => {
288                assert_eq!(text, "think");
289                assert_eq!(redacted, Some("secret".into()));
290            }
291            other => panic!("expected ThinkingDelta, got {:?}", other),
292        }
293    }
294
295    #[test]
296    fn test_to_chunk_usage_none() {
297        let event = AgentStreamEvent::Usage(TokenUsage::default());
298        assert!(event.to_chunk().is_none());
299    }
300
301    #[test]
302    fn test_to_chunk_response_complete_none() {
303        let event = AgentStreamEvent::ResponseComplete;
304        assert!(event.to_chunk().is_none());
305    }
306
307    // ── 向后兼容测试 ──
308
309    #[test]
310    fn test_translate_provider_event_compat() {
311        let event = ProviderEvent::Token {
312            token: "hello".into(),
313        };
314        match translate_provider_event(&event) {
315            TranslationResult::EmitWithText {
316                chunk: StreamChunk::TextDelta(t),
317                delta,
318            } => {
319                assert_eq!(t, "hello");
320                assert_eq!(delta, "hello");
321            }
322            other => panic!("expected EmitWithText(TextDelta), got {:?}", other),
323        }
324    }
325
326    #[test]
327    fn test_ignore_protocol_events() {
328        let event = ProviderEvent::Start {
329            model: "test".into(),
330        };
331        assert!(matches!(
332            translate_provider_event(&event),
333            TranslationResult::Ignore
334        ));
335    }
336}