entari-plugin-hyw 4.0.0rc10__py3-none-any.whl → 4.0.0rc12__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hyw_core/agent.py ADDED
@@ -0,0 +1,648 @@
1
+ """
2
+ Agent Pipeline
3
+
4
+ Tool-calling agent that can autonomously use web_tool to search/screenshot.
5
+ Maximum 2 tool calls, then forced summary.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import re
11
+ import time
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Callable, Awaitable, Dict, List, Optional
14
+
15
+ from loguru import logger
16
+ from openai import AsyncOpenAI
17
+
18
+ from .definitions import get_web_tool, get_refuse_answer_tool, AGENT_SYSTEM_PROMPT
19
+ from .stages.base import StageContext, StageResult
20
+ from .search import SearchService
21
+
22
+
23
+ @dataclass
24
+ class AgentSession:
25
+ """Agent session with tool call tracking."""
26
+ session_id: str
27
+ user_query: str
28
+ tool_calls: List[Dict[str, Any]] = field(default_factory=list)
29
+ tool_results: List[Dict[str, Any]] = field(default_factory=list)
30
+ conversation_history: List[Dict] = field(default_factory=list)
31
+ messages: List[Dict] = field(default_factory=list) # LLM conversation
32
+ created_at: float = field(default_factory=time.time)
33
+
34
+ # Image tracking
35
+ user_image_count: int = 0 # Number of images from user input
36
+ total_image_count: int = 0 # Total images including web screenshots
37
+
38
+ # Time tracking
39
+ search_time: float = 0.0 # Total time spent on search/screenshot
40
+ llm_time: float = 0.0 # Total time spent on LLM calls
41
+ first_llm_time: float = 0.0 # Time for first LLM call (understanding intent)
42
+
43
+ # Usage tracking
44
+ usage_totals: Dict[str, int] = field(default_factory=lambda: {"input_tokens": 0, "output_tokens": 0})
45
+
46
+ @property
47
+ def call_count(self) -> int:
48
+ return len(self.tool_calls)
49
+
50
+ @property
51
+ def should_force_summary(self) -> bool:
52
+ """第3次调用时强制总结"""
53
+ return self.call_count >= 2
54
+
55
+
56
+ def parse_filter_syntax(query: str, max_count: int = 3):
57
+ """
58
+ Parse enhanced filter syntax supporting:
59
+ - Chinese/English colons (: :) and commas (, ,)
60
+ - Multiple filters: "mcmod=2, github=1 : xxx"
61
+ - Index lists: "1, 2, 3 : xxx"
62
+ - Max total selections
63
+
64
+ Returns:
65
+ filters: list of (filter_type, filter_value, count) tuples
66
+ filter_type: 'index' or 'link'
67
+ filter_value: int (for index) or str (for link match term)
68
+ count: how many to get (default 1)
69
+ search_query: the actual search query
70
+ error_msg: error message if exceeded max
71
+ """
72
+ import re
73
+
74
+ # Skip filter parsing if query contains URL (has :// pattern)
75
+ if re.search(r'https?://', query):
76
+ return [], query.strip(), None
77
+
78
+ # Normalize colons
79
+ query = query.replace(':', ':')
80
+
81
+ if ':' not in query:
82
+ return [], query.strip(), None
83
+
84
+ parts = query.split(':', 1)
85
+ if len(parts) != 2:
86
+ return [], query.strip(), None
87
+
88
+ filter_part = parts[0].strip()
89
+ search_query = parts[1].strip()
90
+
91
+ if not filter_part or not search_query:
92
+ return [], query.strip(), None
93
+
94
+ # Parse filter expressions
95
+ filters = []
96
+ total_count = 0
97
+
98
+ # Normalize commas
99
+ filter_part = filter_part.replace(',', ',').replace('、', ',')
100
+ filter_items = [f.strip() for f in filter_part.split(',') if f.strip()]
101
+
102
+ for item in filter_items:
103
+ # Check for "term=count" format (link filter)
104
+ if '=' in item:
105
+ term, count_str = item.split('=', 1)
106
+ term = term.strip().lower()
107
+ try:
108
+ count = int(count_str.strip())
109
+ except ValueError:
110
+ count = 1
111
+ if term and count > 0:
112
+ filters.append(('link', term, count))
113
+ total_count += count
114
+ # Check for pure number (index filter)
115
+ elif item.isdigit():
116
+ idx = int(item)
117
+ if 1 <= idx <= 10:
118
+ filters.append(('index', idx, 1))
119
+ total_count += 1
120
+
121
+ if total_count > max_count:
122
+ return None, search_query, f"⚠️ 最多选择{max_count}个结果"
123
+
124
+ return filters, search_query, None
125
+
126
+
127
+ class AgentPipeline:
128
+ """
129
+ Tool-calling agent pipeline.
130
+
131
+ Flow:
132
+ 1. 用户输入 → LLM (with tools)
133
+ 2. If tool_call: execute tool → notify user → loop
134
+ 3. If call_count >= 2: force summary on next call
135
+ 4. Return final content
136
+ """
137
+
138
+ MAX_TOOL_CALLS = 2
139
+
140
+ def __init__(
141
+ self,
142
+ config: Any,
143
+ search_service: SearchService,
144
+ send_func: Optional[Callable[[str], Awaitable[None]]] = None
145
+ ):
146
+ self.config = config
147
+ self.search_service = search_service
148
+ self.send_func = send_func
149
+ self.client = AsyncOpenAI(base_url=config.base_url, api_key=config.api_key)
150
+
151
+ async def execute(
152
+ self,
153
+ user_input: str,
154
+ conversation_history: List[Dict],
155
+ images: List[str] = None,
156
+ model_name: str = None,
157
+ ) -> Dict[str, Any]:
158
+ """Execute agent with tool-calling loop."""
159
+ start_time = time.time()
160
+
161
+ # Get model config
162
+ model_cfg = self.config.get_model_config("main")
163
+ model = model_name or model_cfg.model_name or self.config.model_name
164
+
165
+ client = AsyncOpenAI(
166
+ base_url=model_cfg.base_url or self.config.base_url,
167
+ api_key=model_cfg.api_key or self.config.api_key
168
+ )
169
+
170
+ # Create session
171
+ session = AgentSession(
172
+ session_id=str(time.time()),
173
+ user_query=user_input,
174
+ conversation_history=conversation_history.copy()
175
+ )
176
+
177
+ # Create context for results
178
+ context = StageContext(
179
+ user_input=user_input,
180
+ images=images or [],
181
+ conversation_history=conversation_history,
182
+ )
183
+
184
+ # Build initial messages
185
+ language = getattr(self.config, "language", "Simplified Chinese")
186
+ system_prompt = AGENT_SYSTEM_PROMPT + f"\n\n用户要求的语言: {language}"
187
+
188
+ # Build user content with images if provided
189
+ user_image_count = len(images) if images else 0
190
+ session.user_image_count = user_image_count
191
+ session.total_image_count = user_image_count
192
+
193
+ if images:
194
+ user_content: List[Dict[str, Any]] = [{"type": "text", "text": user_input}]
195
+ for img_b64 in images:
196
+ url = f"data:image/jpeg;base64,{img_b64}" if not img_b64.startswith("data:") else img_b64
197
+ user_content.append({"type": "image_url", "image_url": {"url": url}})
198
+ else:
199
+ user_content = user_input
200
+
201
+ session.messages = [
202
+ {"role": "system", "content": system_prompt},
203
+ {"role": "user", "content": user_content}
204
+ ]
205
+
206
+ # Add image source hint for user images
207
+ if user_image_count > 0:
208
+ if user_image_count == 1:
209
+ hint = "第1张图片来自用户输入,请将这张图片作为用户输入的参考"
210
+ else:
211
+ hint = f"第1-{user_image_count}张图片来自用户输入,请将这{user_image_count}张图片作为用户输入的参考"
212
+ session.messages.append({"role": "system", "content": hint})
213
+
214
+ # Tool definitions
215
+ web_tool = get_web_tool()
216
+ refuse_tool = get_refuse_answer_tool()
217
+ tools = [web_tool, refuse_tool]
218
+
219
+ usage_totals = {"input_tokens": 0, "output_tokens": 0}
220
+ final_content = ""
221
+
222
+ # Send initial status notification
223
+ if self.send_func:
224
+ try:
225
+ await self.send_func("💭 正在理解用户意图...")
226
+ except Exception as e:
227
+ logger.warning(f"AgentPipeline: Failed to send initial notification: {e}")
228
+
229
+ # Agent loop
230
+ while True:
231
+ # Check if we need to force summary (no tools)
232
+ if session.should_force_summary:
233
+ logger.info(f"AgentPipeline: Max tool calls ({self.MAX_TOOL_CALLS}) reached, forcing summary")
234
+ # Add context message about collected info
235
+ if context.web_results:
236
+ context_msg = self._format_web_context(context)
237
+ session.messages.append({
238
+ "role": "system",
239
+ "content": f"你已经完成了{session.call_count}次工具调用。请基于已收集的信息给出最终回答。\n\n{context_msg}"
240
+ })
241
+
242
+
243
+ # Final call without tools
244
+ response = await client.chat.completions.create(
245
+ model=model,
246
+ messages=session.messages,
247
+ temperature=self.config.temperature,
248
+ )
249
+
250
+ if response.usage:
251
+ usage_totals["input_tokens"] += response.usage.prompt_tokens or 0
252
+ usage_totals["output_tokens"] += response.usage.completion_tokens or 0
253
+
254
+ final_content = response.choices[0].message.content or ""
255
+ break
256
+
257
+ # Normal call with tools
258
+ llm_start = time.time()
259
+ try:
260
+ response = await client.chat.completions.create(
261
+ model=model,
262
+ messages=session.messages,
263
+ temperature=self.config.temperature,
264
+ tools=tools,
265
+ tool_choice="auto",
266
+ )
267
+ except Exception as e:
268
+ logger.error(f"AgentPipeline: LLM error: {e}")
269
+ return {
270
+ "llm_response": f"Error: {e}",
271
+ "success": False,
272
+ "error": str(e),
273
+ "stats": {"total_time": time.time() - start_time}
274
+ }
275
+
276
+ llm_duration = time.time() - llm_start
277
+ session.llm_time += llm_duration
278
+
279
+ # Track first LLM call time (理解用户意图)
280
+ if session.call_count == 0 and session.first_llm_time == 0:
281
+ session.first_llm_time = llm_duration
282
+
283
+ if response.usage:
284
+ usage_totals["input_tokens"] += response.usage.prompt_tokens or 0
285
+ usage_totals["output_tokens"] += response.usage.completion_tokens or 0
286
+
287
+ message = response.choices[0].message
288
+
289
+ # Check for tool calls
290
+ if not message.tool_calls:
291
+ # Model chose to answer directly
292
+ final_content = message.content or ""
293
+ logger.info(f"AgentPipeline: Model answered directly after {session.call_count} tool calls")
294
+ break
295
+
296
+ # Add assistant message with tool calls
297
+ session.messages.append({
298
+ "role": "assistant",
299
+ "content": message.content,
300
+ "tool_calls": [
301
+ {
302
+ "id": tc.id,
303
+ "type": "function",
304
+ "function": {"name": tc.function.name, "arguments": tc.function.arguments}
305
+ }
306
+ for tc in message.tool_calls
307
+ ]
308
+ })
309
+
310
+ # Execute tool calls
311
+ for tool_call in message.tool_calls:
312
+ tc_id = tool_call.id
313
+ func_name = tool_call.function.name
314
+
315
+ try:
316
+ args = json.loads(tool_call.function.arguments)
317
+ except json.JSONDecodeError:
318
+ args = {}
319
+
320
+ logger.info(f"AgentPipeline: Executing tool '{func_name}' with args: {args}")
321
+
322
+ if func_name == "refuse_answer":
323
+ # Handle refusal
324
+ reason = args.get("reason", "Refused")
325
+ context.should_refuse = True
326
+ context.refuse_reason = reason
327
+
328
+ session.messages.append({
329
+ "role": "tool",
330
+ "tool_call_id": tc_id,
331
+ "content": f"已拒绝回答: {reason}"
332
+ })
333
+
334
+ return {
335
+ "llm_response": "",
336
+ "success": True,
337
+ "refuse_answer": True,
338
+ "refuse_reason": reason,
339
+ "stats": {"total_time": time.time() - start_time},
340
+ "usage": usage_totals,
341
+ }
342
+
343
+ elif func_name == "web_tool":
344
+ # Execute web tool with time tracking
345
+ search_start = time.time()
346
+ result = await self._execute_web_tool(args, context)
347
+ session.search_time += time.time() - search_start
348
+
349
+ # Track tool call
350
+ session.tool_calls.append({"name": func_name, "args": args})
351
+ session.tool_results.append(result)
352
+
353
+ # Send IM notification with search result (NOT "正在搜索...")
354
+ if self.send_func:
355
+ try:
356
+ await self.send_func(f"🔍 {result['summary']}")
357
+ except Exception as e:
358
+ logger.warning(f"AgentPipeline: Failed to send notification: {e}")
359
+
360
+ # Add tool result to messages
361
+ result_content = f"搜索完成: {result['summary']}\n\n找到 {len(result.get('results', []))} 个结果"
362
+ session.messages.append({
363
+ "role": "tool",
364
+ "tool_call_id": tc_id,
365
+ "content": result_content
366
+ })
367
+
368
+ # Add image source hint for web screenshots
369
+ screenshot_count = result.get("screenshot_count", 0)
370
+ if screenshot_count > 0:
371
+ start_idx = session.total_image_count + 1
372
+ end_idx = session.total_image_count + screenshot_count
373
+ session.total_image_count = end_idx
374
+
375
+ source_desc = result.get("source_desc", "网页截图")
376
+ if start_idx == end_idx:
377
+ hint = f"第{start_idx}张图片来自{source_desc},作为查询的参考资料"
378
+ else:
379
+ hint = f"第{start_idx}-{end_idx}张图片来自{source_desc},作为查询的参考资料"
380
+ session.messages.append({"role": "system", "content": hint})
381
+ else:
382
+ # Unknown tool
383
+ session.messages.append({
384
+ "role": "tool",
385
+ "tool_call_id": tc_id,
386
+ "content": f"Unknown tool: {func_name}"
387
+ })
388
+
389
+ # Build final response
390
+ total_time = time.time() - start_time
391
+ stats = {"total_time": total_time}
392
+
393
+ # Update conversation history
394
+ conversation_history.append({"role": "user", "content": user_input})
395
+ conversation_history.append({"role": "assistant", "content": final_content})
396
+
397
+ stages_used = self._build_stages_ui(session, context, usage_totals, total_time)
398
+ logger.info(f"AgentPipeline: Built stages_used = {stages_used}")
399
+
400
+ return {
401
+ "llm_response": final_content,
402
+ "success": True,
403
+ "stats": stats,
404
+ "model_used": model,
405
+ "conversation_history": conversation_history,
406
+ "usage": usage_totals,
407
+ "web_results": context.web_results,
408
+ "tool_calls_count": session.call_count,
409
+ "stages_used": stages_used,
410
+ }
411
+
412
+ async def _execute_web_tool(self, args: Dict, context: StageContext) -> Dict[str, Any]:
413
+ """执行 web_tool - 复用 /w 逻辑,支持过滤器语法"""
414
+ query = args.get("query", "")
415
+
416
+ # 1. URL 截图模式 - 检测 query 中是否包含 URL
417
+ url_match = re.search(r'https?://\S+', query)
418
+ if url_match:
419
+ url = url_match.group(0)
420
+ # Send URL screenshot notification
421
+ if self.send_func:
422
+ try:
423
+ short_url = url[:40] + "..." if len(url) > 40 else url
424
+ await self.send_func(f"📸 正在截图: {short_url}")
425
+ except Exception:
426
+ pass
427
+
428
+ logger.info(f"AgentPipeline: Screenshot URL with content: {url}")
429
+ # Use screenshot_with_content to get both screenshot and text
430
+ result = await self.search_service.screenshot_with_content(url)
431
+ screenshot_b64 = result.get("screenshot_b64")
432
+ content = result.get("content", "")
433
+ title = result.get("title", "")
434
+
435
+ if screenshot_b64:
436
+ context.web_results.append({
437
+ "_id": context.next_id(),
438
+ "_type": "page",
439
+ "url": url,
440
+ "title": title or "Screenshot",
441
+ "screenshot_b64": screenshot_b64,
442
+ "content": content, # Text content for LLM
443
+ })
444
+ return {
445
+ "summary": f"已截图: {url[:50]}{'...' if len(url) > 50 else ''}",
446
+ "results": [{"_type": "screenshot", "url": url}],
447
+ "screenshot_count": 1,
448
+ "source_desc": f"URL截图 ({url[:30]}...)"
449
+ }
450
+ return {
451
+ "summary": f"截图失败: {url[:50]}",
452
+ "results": [],
453
+ "screenshot_count": 0
454
+ }
455
+
456
+ # 2. 解析过滤器语法
457
+ filters, search_query, error = parse_filter_syntax(query, max_count=3)
458
+
459
+ if error:
460
+ return {"summary": error, "results": []}
461
+
462
+ # 3. 如果有过滤器,发送搜索+截图预告
463
+ if filters and self.send_func:
464
+ try:
465
+ # Build filter description
466
+ filter_desc_parts = []
467
+ for f_type, f_val, f_count in filters:
468
+ if f_type == 'index':
469
+ filter_desc_parts.append(f"第{f_val}个")
470
+ else:
471
+ filter_desc_parts.append(f"{f_val}={f_count}")
472
+ filter_desc = ", ".join(filter_desc_parts)
473
+ await self.send_func(f"🔍 正在搜索 \"{search_query}\" 并匹配 [{filter_desc}]...")
474
+ except Exception:
475
+ pass
476
+
477
+ logger.info(f"AgentPipeline: Searching for: {search_query}")
478
+ results = await self.search_service.search(search_query)
479
+ visible = [r for r in results if not r.get("_hidden")]
480
+
481
+ # Add search results to context
482
+ for r in results:
483
+ r["_id"] = context.next_id()
484
+ if "_type" not in r:
485
+ r["_type"] = "search"
486
+ r["query"] = search_query
487
+ context.web_results.append(r)
488
+
489
+ # 4. 如果有过滤器,截图匹配的链接
490
+ if filters:
491
+ urls = self._collect_filter_urls(filters, visible)
492
+ if urls:
493
+ logger.info(f"AgentPipeline: Taking screenshots with content of {len(urls)} URLs")
494
+ # Use screenshot_with_content to get both screenshot and text
495
+ screenshot_tasks = [self.search_service.screenshot_with_content(u) for u in urls]
496
+ results = await asyncio.gather(*screenshot_tasks)
497
+
498
+ # Add screenshots and content to context
499
+ successful_count = 0
500
+ for url, result in zip(urls, results):
501
+ screenshot_b64 = result.get("screenshot_b64") if isinstance(result, dict) else None
502
+ content = result.get("content", "") if isinstance(result, dict) else ""
503
+ title = result.get("title", "") if isinstance(result, dict) else ""
504
+
505
+ if screenshot_b64:
506
+ successful_count += 1
507
+ # Find and update the matching result
508
+ for r in context.web_results:
509
+ if r.get("url") == url:
510
+ r["screenshot_b64"] = screenshot_b64
511
+ r["content"] = content # Text content for LLM
512
+ r["title"] = title or r.get("title", "")
513
+ r["_type"] = "page"
514
+ break
515
+
516
+ return {
517
+ "summary": f"搜索 \"{search_query}\" 并截图 {successful_count} 个匹配结果",
518
+ "results": [{"url": u, "_type": "page"} for u in urls],
519
+ "screenshot_count": successful_count,
520
+ "source_desc": f"搜索 \"{search_query}\" 的网页截图"
521
+ }
522
+
523
+ # 5. 普通搜索模式 (无截图)
524
+ return {
525
+ "summary": f"搜索 \"{search_query}\" 找到 {len(visible)} 条结果",
526
+ "results": visible,
527
+ "screenshot_count": 0
528
+ }
529
+
530
+ def _collect_filter_urls(self, filters: List, visible: List[Dict]) -> List[str]:
531
+ """Collect URLs based on filter specifications."""
532
+ urls = []
533
+
534
+ for filter_type, filter_value, count in filters:
535
+ if filter_type == 'index':
536
+ idx = filter_value - 1 # Convert to 0-based
537
+ if 0 <= idx < len(visible):
538
+ url = visible[idx].get("url", "")
539
+ if url and url not in urls:
540
+ urls.append(url)
541
+ else:
542
+ # Link filter
543
+ found_count = 0
544
+ for res in visible:
545
+ url = res.get("url", "")
546
+ title = res.get("title", "")
547
+ # Match filter against both URL and title
548
+ if (filter_value in url.lower() or filter_value in title.lower()) and url not in urls:
549
+ urls.append(url)
550
+ found_count += 1
551
+ if found_count >= count:
552
+ break
553
+
554
+ return urls
555
+
556
+ def _format_web_context(self, context: StageContext) -> str:
557
+ """Format web results for summary context."""
558
+ if not context.web_results:
559
+ return ""
560
+
561
+ lines = ["## 已收集的信息\n"]
562
+ for r in context.web_results:
563
+ idx = r.get("_id", "?")
564
+ title = r.get("title", "Untitled")
565
+ url = r.get("url", "")
566
+ content = r.get("content", "")[:500] if r.get("content") else ""
567
+ has_screenshot = "有截图" if r.get("screenshot_b64") else ""
568
+
569
+ lines.append(f"[{idx}] {title}")
570
+ if url:
571
+ lines.append(f" URL: {url}")
572
+ if has_screenshot:
573
+ lines.append(f" {has_screenshot}")
574
+ if content:
575
+ lines.append(f" 摘要: {content[:200]}...")
576
+ lines.append("")
577
+
578
+ return "\n".join(lines)
579
+
580
+ def _build_stages_ui(self, session: AgentSession, context: StageContext, usage_totals: Dict, total_time: float) -> List[Dict[str, Any]]:
581
+ """Build stages UI for rendering - compatible with App.vue flow section.
582
+
583
+ Flow: Instruct (意图) → Search (搜索) → Summary (总结)
584
+ """
585
+ stages = []
586
+
587
+ # Get model config for pricing
588
+ model_cfg = self.config.get_model_config("main")
589
+ model_name = model_cfg.model_name or self.config.model_name
590
+ input_price = getattr(model_cfg, "input_price", 0) or 0
591
+ output_price = getattr(model_cfg, "output_price", 0) or 0
592
+
593
+ # 1. Instruct Stage (理解用户意图 - 第一次LLM调用)
594
+ if session.first_llm_time > 0:
595
+ # Estimate tokens for first call (rough split based on proportion)
596
+ # Since we track total usage, we approximate first call as ~40% of total
597
+ first_call_ratio = 0.4 if session.call_count > 0 else 1.0
598
+ instruct_input = int(usage_totals.get("input_tokens", 0) * first_call_ratio)
599
+ instruct_output = int(usage_totals.get("output_tokens", 0) * first_call_ratio)
600
+ instruct_cost = (instruct_input * input_price + instruct_output * output_price) / 1_000_000
601
+
602
+ stages.append({
603
+ "name": "Instruct",
604
+ "model": model_name,
605
+ "provider": model_cfg.model_provider or "OpenRouter",
606
+ "description": "理解用户意图",
607
+ "time": session.first_llm_time,
608
+ "usage": {"input_tokens": instruct_input, "output_tokens": instruct_output},
609
+ "cost": instruct_cost,
610
+ })
611
+
612
+ # 2. Search Stage (搜索)
613
+ if session.tool_calls:
614
+ # Collect all search descriptions
615
+ search_descriptions = []
616
+ for tc, result in zip(session.tool_calls, session.tool_results):
617
+ desc = result.get("summary", "")
618
+ if desc:
619
+ search_descriptions.append(desc)
620
+
621
+ stages.append({
622
+ "name": "Search",
623
+ "model": "",
624
+ "provider": "Web",
625
+ "description": " → ".join(search_descriptions) if search_descriptions else "Web Search",
626
+ "time": session.search_time,
627
+ })
628
+
629
+ # 3. Summary Stage (总结)
630
+ # Calculate remaining tokens after instruct
631
+ summary_ratio = 0.6 if session.call_count > 0 else 0.0
632
+ summary_input = int(usage_totals.get("input_tokens", 0) * summary_ratio)
633
+ summary_output = int(usage_totals.get("output_tokens", 0) * summary_ratio)
634
+ summary_cost = (summary_input * input_price + summary_output * output_price) / 1_000_000
635
+ summary_time = session.llm_time - session.first_llm_time
636
+
637
+ if summary_time > 0 or session.call_count > 0:
638
+ stages.append({
639
+ "name": "Summary",
640
+ "model": model_name,
641
+ "provider": model_cfg.model_provider or "OpenRouter",
642
+ "description": f"生成回答 ({session.call_count} 次工具调用)",
643
+ "time": max(0, summary_time),
644
+ "usage": {"input_tokens": summary_input, "output_tokens": summary_output},
645
+ "cost": summary_cost,
646
+ })
647
+
648
+ return stages