kweaver-dolphin 0.1.0__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.
Files changed (199) hide show
  1. DolphinLanguageSDK/__init__.py +58 -0
  2. dolphin/__init__.py +62 -0
  3. dolphin/cli/__init__.py +20 -0
  4. dolphin/cli/args/__init__.py +9 -0
  5. dolphin/cli/args/parser.py +567 -0
  6. dolphin/cli/builtin_agents/__init__.py +22 -0
  7. dolphin/cli/commands/__init__.py +4 -0
  8. dolphin/cli/interrupt/__init__.py +8 -0
  9. dolphin/cli/interrupt/handler.py +205 -0
  10. dolphin/cli/interrupt/keyboard.py +82 -0
  11. dolphin/cli/main.py +49 -0
  12. dolphin/cli/multimodal/__init__.py +34 -0
  13. dolphin/cli/multimodal/clipboard.py +327 -0
  14. dolphin/cli/multimodal/handler.py +249 -0
  15. dolphin/cli/multimodal/image_processor.py +214 -0
  16. dolphin/cli/multimodal/input_parser.py +149 -0
  17. dolphin/cli/runner/__init__.py +8 -0
  18. dolphin/cli/runner/runner.py +989 -0
  19. dolphin/cli/ui/__init__.py +10 -0
  20. dolphin/cli/ui/console.py +2795 -0
  21. dolphin/cli/ui/input.py +340 -0
  22. dolphin/cli/ui/layout.py +425 -0
  23. dolphin/cli/ui/stream_renderer.py +302 -0
  24. dolphin/cli/utils/__init__.py +8 -0
  25. dolphin/cli/utils/helpers.py +135 -0
  26. dolphin/cli/utils/version.py +49 -0
  27. dolphin/core/__init__.py +107 -0
  28. dolphin/core/agent/__init__.py +10 -0
  29. dolphin/core/agent/agent_state.py +69 -0
  30. dolphin/core/agent/base_agent.py +970 -0
  31. dolphin/core/code_block/__init__.py +0 -0
  32. dolphin/core/code_block/agent_init_block.py +0 -0
  33. dolphin/core/code_block/assign_block.py +98 -0
  34. dolphin/core/code_block/basic_code_block.py +1865 -0
  35. dolphin/core/code_block/explore_block.py +1327 -0
  36. dolphin/core/code_block/explore_block_v2.py +712 -0
  37. dolphin/core/code_block/explore_strategy.py +672 -0
  38. dolphin/core/code_block/judge_block.py +220 -0
  39. dolphin/core/code_block/prompt_block.py +32 -0
  40. dolphin/core/code_block/skill_call_deduplicator.py +291 -0
  41. dolphin/core/code_block/tool_block.py +129 -0
  42. dolphin/core/common/__init__.py +17 -0
  43. dolphin/core/common/constants.py +176 -0
  44. dolphin/core/common/enums.py +1173 -0
  45. dolphin/core/common/exceptions.py +133 -0
  46. dolphin/core/common/multimodal.py +539 -0
  47. dolphin/core/common/object_type.py +165 -0
  48. dolphin/core/common/output_format.py +432 -0
  49. dolphin/core/common/types.py +36 -0
  50. dolphin/core/config/__init__.py +16 -0
  51. dolphin/core/config/global_config.py +1289 -0
  52. dolphin/core/config/ontology_config.py +133 -0
  53. dolphin/core/context/__init__.py +12 -0
  54. dolphin/core/context/context.py +1580 -0
  55. dolphin/core/context/context_manager.py +161 -0
  56. dolphin/core/context/var_output.py +82 -0
  57. dolphin/core/context/variable_pool.py +356 -0
  58. dolphin/core/context_engineer/__init__.py +41 -0
  59. dolphin/core/context_engineer/config/__init__.py +5 -0
  60. dolphin/core/context_engineer/config/settings.py +402 -0
  61. dolphin/core/context_engineer/core/__init__.py +7 -0
  62. dolphin/core/context_engineer/core/budget_manager.py +327 -0
  63. dolphin/core/context_engineer/core/context_assembler.py +583 -0
  64. dolphin/core/context_engineer/core/context_manager.py +637 -0
  65. dolphin/core/context_engineer/core/tokenizer_service.py +260 -0
  66. dolphin/core/context_engineer/example/incremental_example.py +267 -0
  67. dolphin/core/context_engineer/example/traditional_example.py +334 -0
  68. dolphin/core/context_engineer/services/__init__.py +5 -0
  69. dolphin/core/context_engineer/services/compressor.py +399 -0
  70. dolphin/core/context_engineer/utils/__init__.py +6 -0
  71. dolphin/core/context_engineer/utils/context_utils.py +441 -0
  72. dolphin/core/context_engineer/utils/message_formatter.py +270 -0
  73. dolphin/core/context_engineer/utils/token_utils.py +139 -0
  74. dolphin/core/coroutine/__init__.py +15 -0
  75. dolphin/core/coroutine/context_snapshot.py +154 -0
  76. dolphin/core/coroutine/context_snapshot_profile.py +922 -0
  77. dolphin/core/coroutine/context_snapshot_store.py +268 -0
  78. dolphin/core/coroutine/execution_frame.py +145 -0
  79. dolphin/core/coroutine/execution_state_registry.py +161 -0
  80. dolphin/core/coroutine/resume_handle.py +101 -0
  81. dolphin/core/coroutine/step_result.py +101 -0
  82. dolphin/core/executor/__init__.py +18 -0
  83. dolphin/core/executor/debug_controller.py +630 -0
  84. dolphin/core/executor/dolphin_executor.py +1063 -0
  85. dolphin/core/executor/executor.py +624 -0
  86. dolphin/core/flags/__init__.py +27 -0
  87. dolphin/core/flags/definitions.py +49 -0
  88. dolphin/core/flags/manager.py +113 -0
  89. dolphin/core/hook/__init__.py +95 -0
  90. dolphin/core/hook/expression_evaluator.py +499 -0
  91. dolphin/core/hook/hook_dispatcher.py +380 -0
  92. dolphin/core/hook/hook_types.py +248 -0
  93. dolphin/core/hook/isolated_variable_pool.py +284 -0
  94. dolphin/core/interfaces.py +53 -0
  95. dolphin/core/llm/__init__.py +0 -0
  96. dolphin/core/llm/llm.py +495 -0
  97. dolphin/core/llm/llm_call.py +100 -0
  98. dolphin/core/llm/llm_client.py +1285 -0
  99. dolphin/core/llm/message_sanitizer.py +120 -0
  100. dolphin/core/logging/__init__.py +20 -0
  101. dolphin/core/logging/logger.py +526 -0
  102. dolphin/core/message/__init__.py +8 -0
  103. dolphin/core/message/compressor.py +749 -0
  104. dolphin/core/parser/__init__.py +8 -0
  105. dolphin/core/parser/parser.py +405 -0
  106. dolphin/core/runtime/__init__.py +10 -0
  107. dolphin/core/runtime/runtime_graph.py +926 -0
  108. dolphin/core/runtime/runtime_instance.py +446 -0
  109. dolphin/core/skill/__init__.py +14 -0
  110. dolphin/core/skill/context_retention.py +157 -0
  111. dolphin/core/skill/skill_function.py +686 -0
  112. dolphin/core/skill/skill_matcher.py +282 -0
  113. dolphin/core/skill/skillkit.py +700 -0
  114. dolphin/core/skill/skillset.py +72 -0
  115. dolphin/core/trajectory/__init__.py +10 -0
  116. dolphin/core/trajectory/recorder.py +189 -0
  117. dolphin/core/trajectory/trajectory.py +522 -0
  118. dolphin/core/utils/__init__.py +9 -0
  119. dolphin/core/utils/cache_kv.py +212 -0
  120. dolphin/core/utils/tools.py +340 -0
  121. dolphin/lib/__init__.py +93 -0
  122. dolphin/lib/debug/__init__.py +8 -0
  123. dolphin/lib/debug/visualizer.py +409 -0
  124. dolphin/lib/memory/__init__.py +28 -0
  125. dolphin/lib/memory/async_processor.py +220 -0
  126. dolphin/lib/memory/llm_calls.py +195 -0
  127. dolphin/lib/memory/manager.py +78 -0
  128. dolphin/lib/memory/sandbox.py +46 -0
  129. dolphin/lib/memory/storage.py +245 -0
  130. dolphin/lib/memory/utils.py +51 -0
  131. dolphin/lib/ontology/__init__.py +12 -0
  132. dolphin/lib/ontology/basic/__init__.py +0 -0
  133. dolphin/lib/ontology/basic/base.py +102 -0
  134. dolphin/lib/ontology/basic/concept.py +130 -0
  135. dolphin/lib/ontology/basic/object.py +11 -0
  136. dolphin/lib/ontology/basic/relation.py +63 -0
  137. dolphin/lib/ontology/datasource/__init__.py +27 -0
  138. dolphin/lib/ontology/datasource/datasource.py +66 -0
  139. dolphin/lib/ontology/datasource/oracle_datasource.py +338 -0
  140. dolphin/lib/ontology/datasource/sql.py +845 -0
  141. dolphin/lib/ontology/mapping.py +177 -0
  142. dolphin/lib/ontology/ontology.py +733 -0
  143. dolphin/lib/ontology/ontology_context.py +16 -0
  144. dolphin/lib/ontology/ontology_manager.py +107 -0
  145. dolphin/lib/skill_results/__init__.py +31 -0
  146. dolphin/lib/skill_results/cache_backend.py +559 -0
  147. dolphin/lib/skill_results/result_processor.py +181 -0
  148. dolphin/lib/skill_results/result_reference.py +179 -0
  149. dolphin/lib/skill_results/skillkit_hook.py +324 -0
  150. dolphin/lib/skill_results/strategies.py +328 -0
  151. dolphin/lib/skill_results/strategy_registry.py +150 -0
  152. dolphin/lib/skillkits/__init__.py +44 -0
  153. dolphin/lib/skillkits/agent_skillkit.py +155 -0
  154. dolphin/lib/skillkits/cognitive_skillkit.py +82 -0
  155. dolphin/lib/skillkits/env_skillkit.py +250 -0
  156. dolphin/lib/skillkits/mcp_adapter.py +616 -0
  157. dolphin/lib/skillkits/mcp_skillkit.py +771 -0
  158. dolphin/lib/skillkits/memory_skillkit.py +650 -0
  159. dolphin/lib/skillkits/noop_skillkit.py +31 -0
  160. dolphin/lib/skillkits/ontology_skillkit.py +89 -0
  161. dolphin/lib/skillkits/plan_act_skillkit.py +452 -0
  162. dolphin/lib/skillkits/resource/__init__.py +52 -0
  163. dolphin/lib/skillkits/resource/models/__init__.py +6 -0
  164. dolphin/lib/skillkits/resource/models/skill_config.py +109 -0
  165. dolphin/lib/skillkits/resource/models/skill_meta.py +127 -0
  166. dolphin/lib/skillkits/resource/resource_skillkit.py +393 -0
  167. dolphin/lib/skillkits/resource/skill_cache.py +215 -0
  168. dolphin/lib/skillkits/resource/skill_loader.py +395 -0
  169. dolphin/lib/skillkits/resource/skill_validator.py +406 -0
  170. dolphin/lib/skillkits/resource_skillkit.py +11 -0
  171. dolphin/lib/skillkits/search_skillkit.py +163 -0
  172. dolphin/lib/skillkits/sql_skillkit.py +274 -0
  173. dolphin/lib/skillkits/system_skillkit.py +509 -0
  174. dolphin/lib/skillkits/vm_skillkit.py +65 -0
  175. dolphin/lib/utils/__init__.py +9 -0
  176. dolphin/lib/utils/data_process.py +207 -0
  177. dolphin/lib/utils/handle_progress.py +178 -0
  178. dolphin/lib/utils/security.py +139 -0
  179. dolphin/lib/utils/text_retrieval.py +462 -0
  180. dolphin/lib/vm/__init__.py +11 -0
  181. dolphin/lib/vm/env_executor.py +895 -0
  182. dolphin/lib/vm/python_session_manager.py +453 -0
  183. dolphin/lib/vm/vm.py +610 -0
  184. dolphin/sdk/__init__.py +60 -0
  185. dolphin/sdk/agent/__init__.py +12 -0
  186. dolphin/sdk/agent/agent_factory.py +236 -0
  187. dolphin/sdk/agent/dolphin_agent.py +1106 -0
  188. dolphin/sdk/api/__init__.py +4 -0
  189. dolphin/sdk/runtime/__init__.py +8 -0
  190. dolphin/sdk/runtime/env.py +363 -0
  191. dolphin/sdk/skill/__init__.py +10 -0
  192. dolphin/sdk/skill/global_skills.py +706 -0
  193. dolphin/sdk/skill/traditional_toolkit.py +260 -0
  194. kweaver_dolphin-0.1.0.dist-info/METADATA +521 -0
  195. kweaver_dolphin-0.1.0.dist-info/RECORD +199 -0
  196. kweaver_dolphin-0.1.0.dist-info/WHEEL +5 -0
  197. kweaver_dolphin-0.1.0.dist-info/entry_points.txt +27 -0
  198. kweaver_dolphin-0.1.0.dist-info/licenses/LICENSE.txt +201 -0
  199. kweaver_dolphin-0.1.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,1173 @@
1
+ import json
2
+ import logging
3
+ import uuid
4
+ from dataclasses import dataclass, field
5
+ from enum import Enum
6
+ from typing import Any, Dict, List, Literal, Optional, Union
7
+ from datetime import datetime
8
+
9
+ from dolphin.core.common.types import Var, SourceType
10
+ from dolphin.core.common.constants import (
11
+ estimate_tokens_from_chars,
12
+ MAX_ANSWER_COMPRESSION_LENGTH,
13
+ ANSWER_CONTENT_PREFIX,
14
+ ANSWER_CONTENT_SUFFIX,
15
+ TOOL_CALL_ID_PREFIX,
16
+ )
17
+ from dolphin.core import flags
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # Type alias for message content - can be plain text or multimodal (OpenAI format)
22
+ MessageContent = Union[str, List[Dict[str, Any]]]
23
+
24
+ PlainMessages = List[Dict[str, Any]]
25
+
26
+
27
+ class MessageRole(Enum):
28
+ """Enumeration type, defines message roles"""
29
+
30
+ USER = "user"
31
+ ASSISTANT = "assistant"
32
+ SYSTEM = "system"
33
+ TOOL = "tool"
34
+
35
+ def __str__(self):
36
+ """Return the string value of the enumeration"""
37
+ return self.value
38
+
39
+
40
+ class CompressLevel(Enum):
41
+ NONE = "none"
42
+ NORMAL = "normal"
43
+
44
+
45
+ class SingleMessage:
46
+ """Single message in a conversation.
47
+
48
+ Supports both plain text content (str) and multimodal content (List[Dict]).
49
+ Multimodal content follows the OpenAI format:
50
+ [
51
+ {"type": "text", "text": "..."},
52
+ {"type": "image_url", "image_url": {"url": "...", "detail": "auto"}}
53
+ ]
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ role: MessageRole,
59
+ content: MessageContent, # Union[str, List[Dict]]
60
+ timestamp: str = datetime.now().isoformat(),
61
+ user_id: str = "",
62
+ tool_calls: Optional[List[Dict[str, Any]]] = None,
63
+ tool_call_id: Optional[str] = None,
64
+ compress_level: CompressLevel = CompressLevel.NONE,
65
+ metadata: Dict[str, Any] = {},
66
+ ):
67
+ self.role = role
68
+ self.content = content
69
+ self.timestamp = timestamp
70
+ self.user_id = user_id
71
+ self.tool_calls = tool_calls or []
72
+ self.tool_call_id = tool_call_id
73
+ self.compress_level = compress_level
74
+ self.metadata = metadata
75
+
76
+ def is_multimodal(self) -> bool:
77
+ """Check if this message contains multimodal content."""
78
+ return isinstance(self.content, list)
79
+
80
+ def has_images(self) -> bool:
81
+ """Check if this message contains any images."""
82
+ if not self.is_multimodal():
83
+ return False
84
+ return any(
85
+ block.get("type") == "image_url"
86
+ for block in self.content
87
+ )
88
+
89
+ def get_image_count(self) -> int:
90
+ """Get the number of images in this message."""
91
+ if not self.is_multimodal():
92
+ return 0
93
+ return sum(
94
+ 1 for block in self.content
95
+ if block.get("type") == "image_url"
96
+ )
97
+
98
+ def extract_text(self) -> str:
99
+ """Extract plain text from this message (excluding images)."""
100
+ if isinstance(self.content, str):
101
+ return self.content
102
+ return "".join(
103
+ block.get("text", "")
104
+ for block in self.content
105
+ if block.get("type") == "text"
106
+ )
107
+
108
+ def normalize_content(self) -> List[Dict[str, Any]]:
109
+ """Normalize content to List[Dict] format."""
110
+ if isinstance(self.content, str):
111
+ return [{"type": "text", "text": self.content}]
112
+ return self.content
113
+
114
+ def append_content(self, new_content: MessageContent):
115
+ """Append content to this message.
116
+
117
+ Append rules:
118
+ - str + str → str (simple concatenation)
119
+ - str + list → list (type upgrade)
120
+ - list + str → list (append text block)
121
+ - list + list → list (merge)
122
+
123
+ Args:
124
+ new_content: Content to append
125
+ """
126
+ current = self.content
127
+
128
+ # Case 1: str + str → str
129
+ if isinstance(current, str) and isinstance(new_content, str):
130
+ self.content = current + new_content
131
+
132
+ # Case 2: str + list → list (type upgrade)
133
+ elif isinstance(current, str) and isinstance(new_content, list):
134
+ self.content = [{"type": "text", "text": current}] + new_content
135
+
136
+ # Case 3: list + str → list (append text block)
137
+ elif isinstance(current, list) and isinstance(new_content, str):
138
+ self.content = current + [{"type": "text", "text": new_content}]
139
+
140
+ # Case 4: list + list → list (merge)
141
+ elif isinstance(current, list) and isinstance(new_content, list):
142
+ self.content = current + new_content
143
+
144
+ def compress(self, compress_level: CompressLevel):
145
+ if compress_level == CompressLevel.NONE:
146
+ return
147
+ elif compress_level == CompressLevel.NORMAL:
148
+ self._compress_normal()
149
+ else:
150
+ raise ValueError(f"Invalid compress level: {compress_level}")
151
+
152
+ def to_dict(self):
153
+ # Handle None metadata defensively (should be {} but can be None from some code paths)
154
+ metadata = self.metadata if self.metadata is not None else {}
155
+ result = {"role": self.role.value, "content": self.content, **metadata}
156
+ if self.tool_calls:
157
+ result["tool_calls"] = self.tool_calls
158
+ if self.tool_call_id:
159
+ result["tool_call_id"] = self.tool_call_id
160
+ return result
161
+
162
+ def length(self) -> int:
163
+ """Get the text length of this message (excluding images)."""
164
+ if isinstance(self.content, str):
165
+ return len(self.content)
166
+ # For multimodal, only count text content
167
+ return sum(
168
+ len(block.get("text", ""))
169
+ for block in self.content
170
+ if block.get("type") == "text"
171
+ )
172
+
173
+ def copy(self):
174
+ """Create a deep copy of this message."""
175
+ # Deep copy content if it's a list
176
+ if isinstance(self.content, list):
177
+ content_copy = [
178
+ {**block} for block in self.content
179
+ ]
180
+ else:
181
+ content_copy = self.content
182
+
183
+ return SingleMessage(
184
+ role=self.role,
185
+ content=content_copy,
186
+ timestamp=self.timestamp,
187
+ user_id=self.user_id,
188
+ metadata=self.metadata.copy() if self.metadata else {},
189
+ compress_level=self.compress_level,
190
+ tool_calls=self.tool_calls.copy() if self.tool_calls else None,
191
+ tool_call_id=self.tool_call_id,
192
+ )
193
+
194
+ def _compress_normal(self):
195
+ """Apply normal compression to the message content.
196
+
197
+ Note: Compression is only applied to text content.
198
+ For multimodal messages, only text blocks are compressed.
199
+ """
200
+ if self.compress_level == CompressLevel.NORMAL:
201
+ return
202
+
203
+ if isinstance(self.content, str):
204
+ self.content = self._compress_answer(self.content)
205
+ self.content = self._compress_cognitive(self.content)
206
+ else:
207
+ # For multimodal, compress each text block
208
+ for block in self.content:
209
+ if block.get("type") == "text" and "text" in block:
210
+ block["text"] = self._compress_answer(block["text"])
211
+ block["text"] = self._compress_cognitive(block["text"])
212
+
213
+ self.compress_level = CompressLevel.NORMAL
214
+
215
+ @staticmethod
216
+ def _compress_answer(content: str) -> str:
217
+ """
218
+ Compress <answer>XXX</answer> content to maximum length, add '...' if truncated
219
+
220
+ Args:
221
+ content (str): The message content to compress
222
+
223
+ Returns:
224
+ str: Compressed content
225
+ """
226
+ import re
227
+
228
+ # Find all <answer>...</answer> blocks
229
+ answer_pattern = re.compile(
230
+ rf"{re.escape(ANSWER_CONTENT_PREFIX)}(.*?){re.escape(ANSWER_CONTENT_SUFFIX)}",
231
+ re.DOTALL,
232
+ )
233
+
234
+ def compress_answer_block(match):
235
+ answer_content = match.group(1)
236
+ if len(answer_content) <= MAX_ANSWER_COMPRESSION_LENGTH:
237
+ return match.group(0) # No compression needed
238
+
239
+ # Truncate and add ellipsis
240
+ compressed_content = answer_content[:MAX_ANSWER_COMPRESSION_LENGTH] + "..."
241
+ return f"{ANSWER_CONTENT_PREFIX}{compressed_content}{ANSWER_CONTENT_SUFFIX}"
242
+
243
+ return answer_pattern.sub(compress_answer_block, content)
244
+
245
+ @staticmethod
246
+ def _compress_cognitive(content: str) -> str:
247
+ """
248
+ Compress cognitive skill call messages using CognitiveSkillkit.compress_msg
249
+
250
+ Args:
251
+ content (str): The message content to compress
252
+
253
+ Returns:
254
+ str: Compressed content
255
+ """
256
+ # Import here to avoid circular import
257
+ from dolphin.lib.skillkits.cognitive_skillkit import (
258
+ CognitiveSkillkit,
259
+ )
260
+
261
+ return CognitiveSkillkit.compress_msg(content)
262
+
263
+ def has_tool_calls(self) -> bool:
264
+ """Check if this message has tool calls"""
265
+ return bool(self.tool_calls)
266
+
267
+ def get_tool_calls(self) -> List[Dict[str, Any]]:
268
+ """Get tool calls"""
269
+ return self.tool_calls or []
270
+
271
+ def add_tool_call(self, tool_call: Dict[str, Any]):
272
+ """Add a tool call to this message"""
273
+ if not self.tool_calls:
274
+ self.tool_calls = []
275
+ self.tool_calls.append(tool_call)
276
+
277
+ def set_tool_call_id(self, tool_call_id: str):
278
+ """Set tool call ID for this message"""
279
+ self.tool_call_id = tool_call_id
280
+
281
+ def get_content_preview(self) -> Dict[str, Any]:
282
+ """Get a preview of content for logging (avoiding sensitive data)."""
283
+ if isinstance(self.content, str):
284
+ return {"type": "text", "length": len(self.content)}
285
+
286
+ return {
287
+ "type": "multimodal",
288
+ "text_length": self.length(),
289
+ "image_count": self.get_image_count()
290
+ }
291
+
292
+ def str_preview(self, max_chars: int = 20) -> str:
293
+ """Generate a compact preview of this message for debugging.
294
+
295
+ Shows role and content preview (head...tail if long, full content if short).
296
+
297
+ Args:
298
+ max_chars: Maximum characters to show from each end of content.
299
+ If content length <= max_chars * 2, show full content.
300
+
301
+ Returns:
302
+ A compact string like '[USER: Hello, how a...your help | 156c]'
303
+ """
304
+ # Get text content
305
+ if isinstance(self.content, list):
306
+ # Multimodal: extract text content
307
+ text_parts = [
308
+ block.get("text", "")
309
+ for block in self.content
310
+ if block.get("type") == "text"
311
+ ]
312
+ text = " ".join(text_parts)
313
+ image_count = self.get_image_count()
314
+ suffix = f"+{image_count}img" if image_count > 0 else ""
315
+ else:
316
+ text = self.content
317
+ suffix = ""
318
+
319
+ # Clean up whitespace for display
320
+ text = text.replace("\n", "↵").replace("\t", "→")
321
+ text_len = len(text)
322
+
323
+ # Generate content preview
324
+ if text_len <= max_chars * 2:
325
+ # Short content: show full
326
+ content_preview = text
327
+ else:
328
+ # Long content: show head...tail
329
+ head = text[:max_chars]
330
+ tail = text[-max_chars:]
331
+ content_preview = f"{head}...{tail}"
332
+
333
+ # Role abbreviation
334
+ role_abbr = {
335
+ MessageRole.SYSTEM: "SYS",
336
+ MessageRole.USER: "USR",
337
+ MessageRole.ASSISTANT: "AST",
338
+ MessageRole.TOOL: "TOL",
339
+ }.get(self.role, self.role.value[:3].upper())
340
+
341
+ return f"[{role_abbr}: {content_preview}{suffix} | {text_len}c]"
342
+
343
+ def __str__(self):
344
+ # For multimodal, show a summary instead of full content
345
+ if isinstance(self.content, list):
346
+ preview = self.get_content_preview()
347
+ content_str = f"[Multimodal: {preview['text_length']} chars, {preview['image_count']} images]"
348
+ else:
349
+ content_str = self.content
350
+ return f"<<{self.role.value}>> {content_str} tool_calls[{self.tool_calls}] tool_call_id[{self.tool_call_id}]"
351
+
352
+
353
+ class KnowledgePoint:
354
+ def __init__(
355
+ self,
356
+ content: str,
357
+ score: int,
358
+ user_id: str,
359
+ type: Literal[
360
+ "WorldModel", "ExperientialKnowledge", "OtherKnowledge"
361
+ ] = "OtherKnowledge",
362
+ metadata: Dict[str, Any] = {},
363
+ ):
364
+ self.content = content
365
+ self.type = type
366
+ self.score = score
367
+ self.user_id = user_id
368
+ self.metadata = metadata
369
+
370
+ def to_dict(self):
371
+ return {
372
+ "content": self.content,
373
+ "type": self.type,
374
+ "score": self.score,
375
+ "user_id": self.user_id,
376
+ "metadata": self.metadata,
377
+ }
378
+
379
+ def from_dict(self, data: dict):
380
+ return KnowledgePoint(
381
+ content=data["content"],
382
+ score=data["score"],
383
+ user_id=data["user_id"],
384
+ type=data["type"],
385
+ metadata=data["metadata"] if "metadata" in data else {},
386
+ )
387
+
388
+ @staticmethod
389
+ def to_prompt(knowledge_points: List["KnowledgePoint"]) -> str:
390
+ return "\n".join([f"{kp.content}" for kp in knowledge_points])
391
+
392
+
393
+ class Messages:
394
+ def __init__(self):
395
+ self.messages: List[SingleMessage] = []
396
+ self.max_tokens = -1
397
+
398
+ def set_max_tokens(self, max_tokens: int):
399
+ self.max_tokens = max_tokens
400
+
401
+ def get_max_tokens(self) -> int:
402
+ return self.max_tokens
403
+
404
+ def clear_messages(self):
405
+ """Clear all messages"""
406
+ self.messages = []
407
+
408
+ def insert_messages(self, messages: "Messages"):
409
+ """Insert messages at the beginning of the message list"""
410
+ converted_messages = messages.get_messages()
411
+ self.messages = converted_messages + self.messages
412
+
413
+ def add_message(
414
+ self,
415
+ content: Any,
416
+ role: MessageRole = MessageRole.USER,
417
+ user_id: str = "",
418
+ metadata: Dict[str, Any] = {},
419
+ tool_calls: Optional[List[Dict[str, Any]]] = None,
420
+ tool_call_id: Optional[str] = None,
421
+ ):
422
+ """Add a message to the conversation.
423
+
424
+ Args:
425
+ content: Message content - can be:
426
+ - str: Plain text content
427
+ - List[Dict]: Multimodal content (OpenAI format)
428
+ - SingleMessage: Existing message object
429
+ - dict: Will be converted to str (for backward compatibility)
430
+ role: Message role (default: USER)
431
+ user_id: User identifier
432
+ metadata: Additional metadata
433
+ tool_calls: Tool call information
434
+ tool_call_id: Tool call ID for tool response messages
435
+ """
436
+ assert content is not None, "content is required"
437
+
438
+ if isinstance(content, str):
439
+ message: SingleMessage = SingleMessage(
440
+ role=role,
441
+ content=content,
442
+ timestamp=datetime.now().isoformat(),
443
+ user_id=user_id,
444
+ metadata=metadata,
445
+ tool_calls=tool_calls,
446
+ tool_call_id=tool_call_id,
447
+ )
448
+ elif isinstance(content, list):
449
+ # Multimodal content (List[Dict])
450
+ message: SingleMessage = SingleMessage(
451
+ role=role,
452
+ content=content,
453
+ timestamp=datetime.now().isoformat(),
454
+ user_id=user_id,
455
+ metadata=metadata,
456
+ tool_calls=tool_calls,
457
+ tool_call_id=tool_call_id,
458
+ )
459
+ elif isinstance(content, SingleMessage):
460
+ message: SingleMessage = content
461
+ elif isinstance(content, dict):
462
+ message: SingleMessage = SingleMessage(
463
+ role=role,
464
+ content=str(content),
465
+ timestamp=datetime.now().isoformat(),
466
+ user_id=user_id,
467
+ metadata=metadata,
468
+ tool_calls=tool_calls,
469
+ tool_call_id=tool_call_id,
470
+ )
471
+ else:
472
+ raise ValueError(f"Invalid message content type: {type(content)}")
473
+ self.messages.append(message)
474
+
475
+ def insert_message(
476
+ self,
477
+ role: MessageRole,
478
+ content: Any,
479
+ user_id: str = "",
480
+ metadata: Dict[str, Any] = {},
481
+ tool_calls: Optional[List[Dict[str, Any]]] = None,
482
+ tool_call_id: Optional[str] = None,
483
+ ):
484
+ """Insert a message at the beginning of the conversation."""
485
+ assert content is not None, "content is required"
486
+
487
+ if isinstance(content, str) or isinstance(content, list):
488
+ # Both str and List[Dict] are valid content types
489
+ message: SingleMessage = SingleMessage(
490
+ role=role,
491
+ content=content,
492
+ timestamp=datetime.now().isoformat(),
493
+ user_id=user_id,
494
+ metadata=metadata,
495
+ tool_calls=tool_calls,
496
+ tool_call_id=tool_call_id,
497
+ )
498
+ elif isinstance(content, SingleMessage):
499
+ message = content
500
+ else:
501
+ raise ValueError(f"Invalid message content type: {type(content)}")
502
+ self.messages.insert(0, message)
503
+
504
+ def prepend_message(
505
+ self,
506
+ role: MessageRole,
507
+ content: Any,
508
+ user_id: str = "",
509
+ metadata: Dict[str, Any] = {},
510
+ tool_calls: Optional[List[Dict[str, Any]]] = None,
511
+ tool_call_id: Optional[str] = None,
512
+ ):
513
+ """Prepend content to the first message if same role, otherwise insert new message."""
514
+ assert content is not None, "content is required"
515
+
516
+ if isinstance(content, SingleMessage):
517
+ content = content.content
518
+
519
+ if len(self.messages) > 0 and self.messages[0].role == role:
520
+ # Use append_content to handle multimodal merging properly
521
+ first_msg = self.messages[0]
522
+ if isinstance(content, str) and isinstance(first_msg.content, str):
523
+ # Both are strings - prepend with newline
524
+ first_msg.content = content + "\n" + first_msg.content
525
+ elif isinstance(content, list) or isinstance(first_msg.content, list):
526
+ # At least one is multimodal - normalize and merge
527
+ new_normalized = content if isinstance(content, list) else [{"type": "text", "text": content}]
528
+ old_normalized = first_msg.normalize_content()
529
+ first_msg.content = new_normalized + old_normalized
530
+ else:
531
+ first_msg.content = content + "\n" + first_msg.content
532
+ else:
533
+ self.insert_message(
534
+ role, content, user_id, metadata, tool_calls, tool_call_id
535
+ )
536
+
537
+ def append_message(
538
+ self,
539
+ role: MessageRole,
540
+ content: Any,
541
+ user_id: str = "",
542
+ metadata: Dict[str, Any] = {},
543
+ tool_calls: Optional[List[Dict[str, Any]]] = None,
544
+ tool_call_id: Optional[str] = None,
545
+ ):
546
+ """Append content to the last message if same role, otherwise add new message."""
547
+ assert content is not None, "content is required"
548
+
549
+ if isinstance(content, SingleMessage):
550
+ content = content.content
551
+
552
+ if len(self.messages) > 0 and self.messages[-1].role == role:
553
+ # Use append_content to handle multimodal merging properly
554
+ last_msg = self.messages[-1]
555
+ if isinstance(content, str) and isinstance(last_msg.content, str):
556
+ # Both are strings - append with newline
557
+ last_msg.content += "\n" + content
558
+ elif isinstance(content, list) or isinstance(last_msg.content, list):
559
+ # At least one is multimodal - use append_content method
560
+ last_msg.append_content(content)
561
+ else:
562
+ last_msg.content += "\n" + content
563
+ else:
564
+ self.add_message(
565
+ role=role,
566
+ content=content,
567
+ user_id=user_id,
568
+ metadata=metadata,
569
+ tool_calls=tool_calls,
570
+ tool_call_id=tool_call_id,
571
+ )
572
+
573
+ def add_messages(
574
+ self,
575
+ role: MessageRole,
576
+ contents: List[Any],
577
+ user_id: str = "",
578
+ metadata: Dict[str, Any] = {},
579
+ ):
580
+ """Add a list of messages to the conversation"""
581
+ for content in contents:
582
+ if isinstance(content, str):
583
+ self.add_message(
584
+ role=role, content=content, user_id=user_id, metadata=metadata
585
+ )
586
+ elif isinstance(content, SingleMessage):
587
+ # Preserve all attributes from the original SingleMessage
588
+ self.add_message(
589
+ role=content.role, # Use original role instead of forcing new role
590
+ content=content.content,
591
+ user_id=content.user_id or user_id,
592
+ metadata={**content.metadata, **metadata},
593
+ tool_calls=content.tool_calls,
594
+ tool_call_id=content.tool_call_id,
595
+ )
596
+ else:
597
+ raise ValueError(f"Invalid message content type: {type(content)}")
598
+ return self
599
+
600
+ def set_messages(self, messages: "Messages"):
601
+ """Set the entire message list"""
602
+ self.messages = messages.get_messages()
603
+
604
+ def get_messages(self) -> List[SingleMessage]:
605
+ """Get all messages"""
606
+ return self.messages
607
+
608
+ def get_messages_as_dict(self) -> PlainMessages:
609
+ """Get all messages as dictionary format for compatibility"""
610
+ return [msg.to_dict() for msg in self.messages]
611
+
612
+ def to_dict(self) -> Dict[str, Any]:
613
+ """Convert the Messages object to a serializable dictionary."""
614
+ return {
615
+ "messages": [msg.to_dict() for msg in self.messages],
616
+ "max_tokens": self.max_tokens,
617
+ }
618
+
619
+ def first(self) -> SingleMessage:
620
+ """Get the first message"""
621
+ return self.messages[0]
622
+
623
+ def last(self) -> SingleMessage:
624
+ """Get the last message"""
625
+ return self.messages[-1]
626
+
627
+ def empty(self) -> bool:
628
+ """Check if the message list is empty"""
629
+ return len(self.messages) == 0
630
+
631
+ def length(self) -> int:
632
+ """Get the total length of the message list"""
633
+ return sum(msg.length() for msg in self.messages)
634
+
635
+ def str_first(self) -> str:
636
+ """Get the first message as string"""
637
+ return str(self.messages[0]).replace("\n", "\\n")
638
+
639
+ def str_last(self) -> str:
640
+ """Get the last message as string"""
641
+ return str(self.messages[-1]).replace("\n", "\\n")
642
+
643
+ def str_summary(self, max_chars: int = 20) -> str:
644
+ """Generate a compact summary of all messages for debugging.
645
+
646
+ Shows a one-line preview of each message with role and content preview.
647
+
648
+ Args:
649
+ max_chars: Maximum characters to show from each end of content.
650
+
651
+ Returns:
652
+ A compact string like:
653
+ '[SYS: You are a he...assistant | 120c] -> [USR: 请帮我分析...这段代码 | 45c] -> [AST: 好的,我来...完成了。 | 892c]'
654
+ """
655
+ if not self.messages:
656
+ return "[Empty]"
657
+
658
+ previews = [msg.str_preview(max_chars) for msg in self.messages]
659
+ return " -> ".join(previews)
660
+
661
+ def have_system_message(self) -> bool:
662
+ """Check if the message list has a system message"""
663
+ return any(msg.role == MessageRole.SYSTEM for msg in self.messages)
664
+
665
+ def extend_messages(self, messages: "Messages"):
666
+ """Extend the message list"""
667
+ self.messages.extend(messages.get_messages())
668
+
669
+ def extend_plain_messages(self, messages: PlainMessages):
670
+ """Extend the message list"""
671
+ for msg in messages:
672
+ # Convert string role to MessageRole enum if needed
673
+ role = MessageRole.USER
674
+ if msg["role"].lower() == "assistant":
675
+ role = MessageRole.ASSISTANT
676
+ elif msg["role"].lower() == "system":
677
+ role = MessageRole.SYSTEM
678
+ elif msg["role"].lower() == "tool":
679
+ role = MessageRole.TOOL
680
+
681
+ singleMsg = SingleMessage(
682
+ role=role,
683
+ content=msg["content"],
684
+ timestamp=(
685
+ msg["timestamp"]
686
+ if "timestamp" in msg
687
+ else datetime.now().isoformat()
688
+ ),
689
+ user_id=msg["user_id"] if "user_id" in msg else "",
690
+ metadata=msg["metadata"] if "metadata" in msg else {},
691
+ tool_calls=msg.get("tool_calls"),
692
+ tool_call_id=msg.get("tool_call_id"),
693
+ )
694
+ self.messages.append(singleMsg)
695
+
696
+ def extract_system_messages(self) -> "Messages":
697
+ """Extract system messages from the message list"""
698
+ system_messages = Messages()
699
+ for msg in self.messages:
700
+ if msg.role == MessageRole.SYSTEM:
701
+ system_messages.add_message(msg)
702
+ return system_messages
703
+
704
+ def extract_non_system_messages(self) -> "Messages":
705
+ """Extract non-system messages from the message list"""
706
+ non_system_messages = Messages()
707
+ for msg in self.messages:
708
+ if msg.role != MessageRole.SYSTEM:
709
+ non_system_messages.add_message(msg)
710
+ return non_system_messages
711
+
712
+ def drop_last(self):
713
+ """Drop the last message"""
714
+ self.messages = self.messages[:-1]
715
+
716
+ def estimated_tokens(self):
717
+ estimated = 0
718
+ for message in self.messages:
719
+ estimated += estimate_tokens_from_chars(message.content)
720
+ return estimated
721
+
722
+ def compress(self, compress_level: CompressLevel):
723
+ """
724
+ Compress all messages in the list
725
+
726
+ Args:
727
+ compress_level (CompressLevel): The compression level to apply
728
+ """
729
+ for message in self.messages:
730
+ message.compress(compress_level)
731
+
732
+ def make_new_messages(self, content: str):
733
+ if not content:
734
+ return self
735
+
736
+ new_messages = Messages()
737
+ new_messages.extend_messages(self)
738
+ new_messages.append_message(MessageRole.USER, content)
739
+ return new_messages
740
+
741
+ def copy(self):
742
+ new_messages = Messages()
743
+ new_messages.messages = [msg.copy() for msg in self.messages]
744
+ return new_messages
745
+
746
+ @staticmethod
747
+ def create_system_message(content: str) -> "SingleMessage":
748
+ """Make a system message"""
749
+ return SingleMessage(
750
+ role=MessageRole.SYSTEM,
751
+ content=content,
752
+ timestamp=datetime.now().isoformat(),
753
+ )
754
+
755
+ @staticmethod
756
+ def combine_messages(
757
+ messages: "Messages", other_messages: "Messages"
758
+ ) -> "Messages":
759
+ """Combine two message lists"""
760
+ combined_messages = Messages()
761
+ combined_messages.extend_messages(messages)
762
+ combined_messages.extend_messages(other_messages)
763
+ return combined_messages
764
+
765
+ def __len__(self) -> int:
766
+ """Return the number of messages"""
767
+ return len(self.messages)
768
+
769
+ def __iter__(self):
770
+ """Allow iteration over messages"""
771
+ return iter(self.messages)
772
+
773
+ def __getitem__(self, index):
774
+ """Allow indexing of messages"""
775
+ return self.messages[index]
776
+
777
+ def __add__(self, other: "Messages") -> "Messages":
778
+ """Add two Messages objects together, preserving all message attributes"""
779
+ if not isinstance(other, Messages):
780
+ raise TypeError(f"Cannot add Messages with {type(other)}")
781
+
782
+ result = self.copy()
783
+ result.extend_messages(other)
784
+ return result
785
+
786
+ def add_tool_call_message(
787
+ self,
788
+ content: str,
789
+ tool_calls: List[Dict[str, Any]],
790
+ user_id: str = "",
791
+ metadata: Dict[str, Any] = {},
792
+ ):
793
+ """Add a message with tool calls (typically assistant role)"""
794
+ self.add_message(
795
+ role=MessageRole.ASSISTANT,
796
+ content=content,
797
+ user_id=user_id,
798
+ metadata=metadata,
799
+ tool_calls=tool_calls,
800
+ )
801
+
802
+ def add_tool_response_message(
803
+ self,
804
+ content: str,
805
+ tool_call_id: str,
806
+ user_id: str = "",
807
+ metadata: Dict[str, Any] = {},
808
+ ):
809
+ """Add a tool response message (tool role)"""
810
+ self.add_message(
811
+ role=MessageRole.TOOL,
812
+ content=content,
813
+ user_id=user_id,
814
+ metadata=metadata,
815
+ tool_call_id=tool_call_id,
816
+ )
817
+
818
+ def get_messages_with_tool_calls(self) -> List[SingleMessage]:
819
+ """Get all messages that have tool calls"""
820
+ return [msg for msg in self.messages if msg.has_tool_calls()]
821
+
822
+ def get_tool_response_messages(self) -> List[SingleMessage]:
823
+ """Get all tool response messages"""
824
+ return [msg for msg in self.messages if msg.tool_call_id is not None]
825
+
826
+ def __str__(self):
827
+ return "\n-----------------------------\n".join(
828
+ [str(msg) for msg in self.messages]
829
+ )
830
+
831
+
832
+ class CategoryBlock(Enum):
833
+ JUDGE = "judge"
834
+ EXPLORE = "explore"
835
+ PROMPT = "prompt"
836
+ TOOL = "tool"
837
+ ASSIGN = "assign"
838
+
839
+
840
+ class SkillType(Enum):
841
+ TOOL = "TOOL"
842
+ AGENT = "AGENT"
843
+ MCP = "MCP"
844
+
845
+
846
+ class SkillArg:
847
+ def __init__(self, name: str, type: str, value: Any):
848
+ self.name = name
849
+ self.type = type
850
+ self.value = value
851
+
852
+ def to_dict(self):
853
+ return {"name": self.name, "type": self.type, "value": self.value}
854
+
855
+
856
+ class SkillInfo:
857
+ def __init__(
858
+ self, type: SkillType, name: str, args: list[SkillArg], checked: bool = True
859
+ ):
860
+ self.type = type
861
+ self.name = name
862
+ self.args = args
863
+ self.checked = checked
864
+
865
+ def to_dict(self):
866
+ return {
867
+ "type": self.type.value,
868
+ "name": self.name,
869
+ "args": [arg.to_dict() for arg in self.args],
870
+ "checked": self.checked,
871
+ }
872
+
873
+ def __str__(self):
874
+ return f"{self.type}: {self.name} {self.args}"
875
+
876
+ @staticmethod
877
+ def build(
878
+ skill_type: SkillType,
879
+ skill_name: str,
880
+ skill_args: dict = {},
881
+ checked: bool = True,
882
+ ) -> "SkillInfo":
883
+ args = [SkillArg(k, type(v).__name__, v) for k, v in skill_args.items()]
884
+ return SkillInfo(skill_type, skill_name, args, checked)
885
+
886
+ @staticmethod
887
+ def from_dict(dict_data: dict) -> Optional["SkillInfo"]:
888
+ if not dict_data:
889
+ return None
890
+ return SkillInfo(
891
+ type=SkillType(dict_data["type"]),
892
+ name=dict_data["name"],
893
+ args=[
894
+ SkillArg(arg["name"], arg["type"], arg["value"])
895
+ for arg in dict_data["args"]
896
+ ],
897
+ checked=dict_data["checked"],
898
+ )
899
+
900
+
901
+ class Status(Enum):
902
+ PROCESSING = "processing"
903
+ COMPLETED = "completed"
904
+ FAILED = "failed"
905
+
906
+
907
+ class TypeStage(Enum):
908
+ LLM = "llm"
909
+ SKILL = "skill"
910
+ ASSIGN = "assign"
911
+
912
+
913
+ def count_occurrences(str_list, target_str):
914
+ total_count = 0
915
+ for s in str_list:
916
+ total_count += target_str.count(s)
917
+ return total_count
918
+
919
+
920
+ @dataclass
921
+ class ToolCallInfo:
922
+ """Single tool call information
923
+
924
+ Used to store parsed tool call data from LLM responses.
925
+ Supports both LLM-provided IDs and fallback ID generation.
926
+
927
+ Attributes:
928
+ id: Unique identifier for this tool call (LLM-provided or generated)
929
+ name: Name of the tool/function to call
930
+ arguments: Parsed arguments dict (None if parsing failed)
931
+ index: Position index in multi-tool-call response
932
+ raw_arguments: Original unparsed arguments string (for debugging)
933
+ is_complete: Whether the tool call arguments have been fully received
934
+ and successfully parsed. False if stream is incomplete or
935
+ JSON parsing failed.
936
+ """
937
+ id: str
938
+ name: str
939
+ arguments: Optional[Dict[str, Any]] = None
940
+ index: int = 0
941
+ raw_arguments: str = ""
942
+ is_complete: bool = False
943
+
944
+
945
+ class StreamItem:
946
+ """Streaming response item from LLM
947
+
948
+ Supports both single tool call (legacy) and multiple tool calls (new).
949
+ Feature flag ENABLE_PARALLEL_TOOL_CALLS controls which behavior is used.
950
+ """
951
+
952
+ def __init__(self):
953
+ self.answer = ""
954
+ self.think = ""
955
+ # Legacy fields (preserved for backward compatibility)
956
+ self.tool_name = ""
957
+ self.tool_args: Optional[dict[str, Any]] = None
958
+ # Stable tool_call_id for single tool call flows
959
+ self.tool_call_id: Optional[str] = None
960
+ # New fields for multiple tool calls
961
+ self.tool_calls: List[ToolCallInfo] = []
962
+ self.finish_reason: Optional[str] = None
963
+ self.output_var_value = None
964
+ self.token_usage = {}
965
+
966
+ def has_tool_call(self) -> bool:
967
+ """Check if there is a tool call (backward compatible).
968
+
969
+ Note: When ENABLE_PARALLEL_TOOL_CALLS flag is disabled, only checks
970
+ the legacy tool_name field to ensure backward compatibility.
971
+ This prevents accidentally triggering new code paths.
972
+ """
973
+ if flags.is_enabled(flags.ENABLE_PARALLEL_TOOL_CALLS):
974
+ return self.tool_name != "" or len(self.tool_calls) > 0
975
+ else:
976
+ # Flag disabled: only check legacy field for backward compatibility
977
+ return self.tool_name != ""
978
+
979
+ def has_tool_calls(self) -> bool:
980
+ """Check if there are multiple tool calls (new method)."""
981
+ return len(self.tool_calls) > 0
982
+
983
+ def has_complete_tool_call(self) -> bool:
984
+ """Check if there is a complete tool call with parsed arguments."""
985
+ return self.tool_name != "" and self.tool_args is not None
986
+
987
+ def set_output_var_value(self, output_var_value):
988
+ self.output_var_value = output_var_value
989
+
990
+ def get_tool_call(self) -> dict[str, Any]:
991
+ """Get single tool call info (legacy method)."""
992
+ return {
993
+ "name": self.tool_name,
994
+ "arguments": self.tool_args,
995
+ }
996
+
997
+ def get_tool_calls(self) -> List[ToolCallInfo]:
998
+ """Get all tool calls.
999
+
1000
+ Returns:
1001
+ List of ToolCallInfo objects. If tool_calls is empty but
1002
+ legacy tool_name is set, returns a single-item list for compatibility.
1003
+ """
1004
+ if self.tool_calls:
1005
+ return self.tool_calls
1006
+ # Fallback to legacy field for backward compatibility
1007
+ if self.tool_name:
1008
+ # Set is_complete=True when tool_args has been successfully parsed
1009
+ # This ensures detect_tool_calls() can properly detect legacy tool calls
1010
+ return [ToolCallInfo(
1011
+ id=f"{TOOL_CALL_ID_PREFIX}{self.tool_name}_0",
1012
+ name=self.tool_name,
1013
+ arguments=self.tool_args,
1014
+ index=0,
1015
+ raw_arguments=json.dumps(self.tool_args) if self.tool_args else "",
1016
+ is_complete=self.tool_args is not None, # Mark complete if args parsed
1017
+ )]
1018
+ return []
1019
+
1020
+ def parse_from_chunk(self, chunk: dict, session_counter: int | None = None):
1021
+ """Parse streaming chunk from LLM response.
1022
+
1023
+ Args:
1024
+ chunk: The LLM response chunk containing content, tool calls, etc.
1025
+ session_counter: Optional session-level tool call batch counter for
1026
+ generating fallback tool_call_ids. If None, a short UUID
1027
+ will be used to ensure uniqueness across sessions.
1028
+ """
1029
+ # Generate a unique batch ID if no session_counter provided
1030
+ batch_id = str(session_counter) if session_counter is not None else uuid.uuid4().hex[:8]
1031
+ self.answer = chunk.get("content", "")
1032
+ self.think = chunk.get("reasoning_content", "")
1033
+ self.token_usage = chunk.get("usage", {})
1034
+ self.finish_reason = chunk.get("finish_reason")
1035
+ self.tool_call_id = None
1036
+
1037
+ # Parse multiple tool calls from tool_calls_data (new format)
1038
+ tool_calls_data = chunk.get("tool_calls_data", {})
1039
+ if tool_calls_data:
1040
+ self.tool_calls = []
1041
+ # Sort by index to ensure correct execution order
1042
+ items = []
1043
+ for index, data in tool_calls_data.items():
1044
+ try:
1045
+ normalized_index = int(index)
1046
+ except Exception:
1047
+ normalized_index = 0
1048
+ items.append((normalized_index, data))
1049
+
1050
+ for normalized_index, data in sorted(items, key=lambda x: x[0]):
1051
+ if data.get("name"):
1052
+ args_str = "".join(data.get("arguments", []))
1053
+ parsed_args = None
1054
+
1055
+ is_complete = False
1056
+ if args_str:
1057
+ try:
1058
+ parsed_args = json.loads(args_str)
1059
+ if isinstance(parsed_args, str):
1060
+ # Double JSON encoding detected - log for debugging
1061
+ logger.debug(
1062
+ f"Double JSON encoding detected for tool call '{data.get('name')}', "
1063
+ f"performing second parse. Original: {args_str[:200]}..."
1064
+ if len(args_str) > 200 else
1065
+ f"Double JSON encoding detected for tool call '{data.get('name')}', "
1066
+ f"performing second parse. Original: {args_str}"
1067
+ )
1068
+ parsed_args = json.loads(parsed_args)
1069
+ is_complete = True # Successfully parsed
1070
+ except json.JSONDecodeError:
1071
+ # Arguments not yet complete, keep as None
1072
+ # is_complete remains False
1073
+ pass
1074
+
1075
+ # Generate or use tool_call_id
1076
+ # Priority: LLM-provided id > fallback with batch_id
1077
+ tool_call_id = data.get("id")
1078
+ if not tool_call_id:
1079
+ # Fallback: use batch_id and index for stable ID
1080
+ # Format: {TOOL_CALL_ID_PREFIX}{batch_id}_{index}
1081
+ tool_call_id = f"{TOOL_CALL_ID_PREFIX}{batch_id}_{normalized_index}"
1082
+
1083
+ self.tool_calls.append(ToolCallInfo(
1084
+ id=tool_call_id,
1085
+ name=data["name"],
1086
+ arguments=parsed_args,
1087
+ index=normalized_index,
1088
+ raw_arguments=args_str,
1089
+ is_complete=is_complete,
1090
+ ))
1091
+
1092
+ # Provide a stable single tool_call_id (index 0) for legacy callers.
1093
+ if self.tool_calls:
1094
+ self.tool_call_id = self.tool_calls[0].id
1095
+
1096
+ # Parse legacy single tool call (backward compatibility)
1097
+ if "func_name" in chunk and chunk["func_name"]:
1098
+ func_args_list = chunk.get("func_args", [])
1099
+ func_args_str = "".join(func_args_list) if func_args_list else ""
1100
+ parsed_args: Optional[dict[str, Any]] = None
1101
+ if func_args_str:
1102
+ try:
1103
+ parsed_args = json.loads(func_args_str)
1104
+ if isinstance(parsed_args, str):
1105
+ # Double JSON encoding detected - log for debugging
1106
+ logger.debug(
1107
+ f"Double JSON encoding detected for legacy tool call '{chunk['func_name']}', "
1108
+ f"performing second parse. Original: {func_args_str[:200]}..."
1109
+ if len(func_args_str) > 200 else
1110
+ f"Double JSON encoding detected for legacy tool call '{chunk['func_name']}', "
1111
+ f"performing second parse. Original: {func_args_str}"
1112
+ )
1113
+ parsed_args = json.loads(parsed_args)
1114
+ except json.JSONDecodeError:
1115
+ # The parameters are not yet complete, parsed_args remains None
1116
+ pass
1117
+
1118
+ self.tool_name = chunk["func_name"]
1119
+ self.tool_args = parsed_args
1120
+ if not self.tool_call_id:
1121
+ self.tool_call_id = f"{TOOL_CALL_ID_PREFIX}{batch_id}_0"
1122
+
1123
+ def to_dict(self):
1124
+ result = {"answer": self.answer, "think": self.think}
1125
+
1126
+ if self.tool_name:
1127
+ result["tool_call"] = self.get_tool_call()
1128
+
1129
+ # Include multiple tool calls if present
1130
+ if self.tool_calls:
1131
+ result["tool_calls"] = [
1132
+ {
1133
+ "id": tc.id,
1134
+ "name": tc.name,
1135
+ "arguments": tc.arguments,
1136
+ "index": tc.index,
1137
+ }
1138
+ for tc in self.tool_calls
1139
+ ]
1140
+
1141
+ if self.output_var_value is not None:
1142
+ result["output_var_value"] = self.output_var_value
1143
+
1144
+ if self.token_usage:
1145
+ result["token_usage"] = self.token_usage
1146
+
1147
+ if self.finish_reason:
1148
+ result["finish_reason"] = self.finish_reason
1149
+
1150
+ return result
1151
+
1152
+
1153
+ class DolphinSDKEncoder(json.JSONEncoder):
1154
+ """Custom JSON encoder for Dolphin SDK objects.
1155
+
1156
+ This encoder handles serialization of Dolphin SDK custom objects that
1157
+ are not natively JSON serializable, such as Messages, VarOutput, etc.
1158
+
1159
+ Uses duck typing to avoid circular import issues.
1160
+ """
1161
+ def default(self, obj):
1162
+ # Handle Enum objects first (most common)
1163
+ if isinstance(obj, Enum):
1164
+ return obj.value
1165
+ # Handle Var objects - extract their value
1166
+ if isinstance(obj, Var):
1167
+ return obj.value
1168
+ # Handle any object with a to_dict() method (duck typing)
1169
+ # This includes Messages, VarOutput, SkillInfo, SingleMessage, etc.
1170
+ if hasattr(obj, 'to_dict') and callable(getattr(obj, 'to_dict')):
1171
+ return obj.to_dict()
1172
+ # Let the base class default method raise the TypeError for other types
1173
+ return super().default(obj)