kolega-code 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 (171) hide show
  1. kolega_code/__init__.py +151 -0
  2. kolega_code/agent/__init__.py +42 -0
  3. kolega_code/agent/baseagent.py +998 -0
  4. kolega_code/agent/browseragent.py +123 -0
  5. kolega_code/agent/coder.py +157 -0
  6. kolega_code/agent/common.py +41 -0
  7. kolega_code/agent/compression.py +81 -0
  8. kolega_code/agent/context.py +112 -0
  9. kolega_code/agent/conversation.py +408 -0
  10. kolega_code/agent/generalagent.py +146 -0
  11. kolega_code/agent/investigationagent.py +123 -0
  12. kolega_code/agent/planningagent.py +187 -0
  13. kolega_code/agent/prompt_provider.py +196 -0
  14. kolega_code/agent/prompt_templates/agents/browser.j2 +102 -0
  15. kolega_code/agent/prompt_templates/agents/coder_cli_mode.j2 +127 -0
  16. kolega_code/agent/prompt_templates/agents/general.j2 +68 -0
  17. kolega_code/agent/prompt_templates/agents/investigation.j2 +72 -0
  18. kolega_code/agent/prompt_templates/common/frontend_guidance.md +36 -0
  19. kolega_code/agent/prompt_templates/common/kolega_md_instructions.md +14 -0
  20. kolega_code/agent/prompt_templates/environment_variables/workspace_env_vars.md +11 -0
  21. kolega_code/agent/prompt_templates/template_guidance/expo-template.md +379 -0
  22. kolega_code/agent/prompt_templates/template_guidance/html-website-template.md +3 -0
  23. kolega_code/agent/prompt_templates/template_guidance/mern-stack-template.md +3 -0
  24. kolega_code/agent/prompt_templates/template_guidance/react-vite-shadcdn-template.md +182 -0
  25. kolega_code/agent/prompts.py +192 -0
  26. kolega_code/agent/tests/__init__.py +0 -0
  27. kolega_code/agent/tests/llm/__init__.py +0 -0
  28. kolega_code/agent/tests/llm/test_anthropic_token_counting.py +633 -0
  29. kolega_code/agent/tests/llm/test_billing_openai_cache.py +74 -0
  30. kolega_code/agent/tests/llm/test_client.py +773 -0
  31. kolega_code/agent/tests/llm/test_dashscope_mapping.py +32 -0
  32. kolega_code/agent/tests/llm/test_error_boundary.py +322 -0
  33. kolega_code/agent/tests/llm/test_exceptions.py +249 -0
  34. kolega_code/agent/tests/llm/test_instrumented_client.py +536 -0
  35. kolega_code/agent/tests/llm/test_instrumented_client_integration.py +547 -0
  36. kolega_code/agent/tests/llm/test_langfuse_normalization.py +39 -0
  37. kolega_code/agent/tests/llm/test_model_specs.py +17 -0
  38. kolega_code/agent/tests/llm/test_openai_cached_tokens.py +58 -0
  39. kolega_code/agent/tests/llm/test_openai_cached_tokens_stream.py +74 -0
  40. kolega_code/agent/tests/llm/test_openai_message_conversion.py +30 -0
  41. kolega_code/agent/tests/llm/test_openai_token_counting.py +687 -0
  42. kolega_code/agent/tests/llm/test_tool_execution_ids.py +193 -0
  43. kolega_code/agent/tests/services/__init__.py +1 -0
  44. kolega_code/agent/tests/services/test_browser.py +447 -0
  45. kolega_code/agent/tests/services/test_browser_parity.py +353 -0
  46. kolega_code/agent/tests/services/test_file_system.py +699 -0
  47. kolega_code/agent/tests/services/test_sandbox_terminal_input.py +98 -0
  48. kolega_code/agent/tests/services/test_terminal.py +154 -0
  49. kolega_code/agent/tests/services/test_terminal_command_tracking.py +385 -0
  50. kolega_code/agent/tests/services/test_terminal_state_serializer.py +262 -0
  51. kolega_code/agent/tests/test_agent_tools_inventory.py +267 -0
  52. kolega_code/agent/tests/test_base_agent.py +1942 -0
  53. kolega_code/agent/tests/test_coder_attachments.py +330 -0
  54. kolega_code/agent/tests/test_coder_prompt_extensions.py +61 -0
  55. kolega_code/agent/tests/test_commands.py +179 -0
  56. kolega_code/agent/tests/test_duplicate_tool_results.py +556 -0
  57. kolega_code/agent/tests/test_empty_message_handling.py +48 -0
  58. kolega_code/agent/tests/test_general_agent.py +242 -0
  59. kolega_code/agent/tests/test_html.py +320 -0
  60. kolega_code/agent/tests/test_parallel_tool_calls.py +291 -0
  61. kolega_code/agent/tests/test_planning_agent.py +227 -0
  62. kolega_code/agent/tests/test_prompt_provider.py +271 -0
  63. kolega_code/agent/tests/test_tool_registry.py +102 -0
  64. kolega_code/agent/tests/test_tools.py +549 -0
  65. kolega_code/agent/tests/tool_backend/__init__.py +0 -0
  66. kolega_code/agent/tests/tool_backend/test_agent_tool.py +356 -0
  67. kolega_code/agent/tests/tool_backend/test_base_tool.py +147 -0
  68. kolega_code/agent/tests/tool_backend/test_browser_tool.py +335 -0
  69. kolega_code/agent/tests/tool_backend/test_build_tool.py +93 -0
  70. kolega_code/agent/tests/tool_backend/test_create_file_tool.py +115 -0
  71. kolega_code/agent/tests/tool_backend/test_glob_tool.py +196 -0
  72. kolega_code/agent/tests/tool_backend/test_glob_tool_sandbox_parity.py +230 -0
  73. kolega_code/agent/tests/tool_backend/test_list_directory_tool.py +292 -0
  74. kolega_code/agent/tests/tool_backend/test_read_file_tool.py +173 -0
  75. kolega_code/agent/tests/tool_backend/test_replace_entire_file_tool.py +115 -0
  76. kolega_code/agent/tests/tool_backend/test_replace_lines_tool.py +141 -0
  77. kolega_code/agent/tests/tool_backend/test_search_and_replace_tool.py +174 -0
  78. kolega_code/agent/tests/tool_backend/test_search_codebase_tool.py +228 -0
  79. kolega_code/agent/tests/tool_backend/test_terminal_tool.py +482 -0
  80. kolega_code/agent/tests/tool_backend/test_think_hard_integration.py +189 -0
  81. kolega_code/agent/tests/tool_backend/test_think_hard_streaming.py +445 -0
  82. kolega_code/agent/tests/tool_backend/test_web_fetch_tool.py +194 -0
  83. kolega_code/agent/tool_backend/agent_tool.py +414 -0
  84. kolega_code/agent/tool_backend/apply_edit_tool.py +98 -0
  85. kolega_code/agent/tool_backend/apply_patch_tool.py +514 -0
  86. kolega_code/agent/tool_backend/base_tool.py +217 -0
  87. kolega_code/agent/tool_backend/browser_tool.py +271 -0
  88. kolega_code/agent/tool_backend/build_tool.py +93 -0
  89. kolega_code/agent/tool_backend/create_file_tool.py +52 -0
  90. kolega_code/agent/tool_backend/glob_tool.py +323 -0
  91. kolega_code/agent/tool_backend/list_directory_tool.py +300 -0
  92. kolega_code/agent/tool_backend/memory_tool.py +79 -0
  93. kolega_code/agent/tool_backend/read_file_tool.py +119 -0
  94. kolega_code/agent/tool_backend/replace_entire_file_tool.py +40 -0
  95. kolega_code/agent/tool_backend/replace_lines_tool.py +97 -0
  96. kolega_code/agent/tool_backend/search_and_replace_tool.py +146 -0
  97. kolega_code/agent/tool_backend/search_codebase_tool.py +377 -0
  98. kolega_code/agent/tool_backend/streaming_tool.py +47 -0
  99. kolega_code/agent/tool_backend/terminal_tool.py +643 -0
  100. kolega_code/agent/tool_backend/think_hard_tool.py +211 -0
  101. kolega_code/agent/tool_backend/web_fetch_tool.py +205 -0
  102. kolega_code/agent/tools.py +1704 -0
  103. kolega_code/agent/utils/commands.py +94 -0
  104. kolega_code/cli/__init__.py +1 -0
  105. kolega_code/cli/app.py +2756 -0
  106. kolega_code/cli/config.py +280 -0
  107. kolega_code/cli/connection.py +49 -0
  108. kolega_code/cli/file_index.py +147 -0
  109. kolega_code/cli/main.py +564 -0
  110. kolega_code/cli/mentions.py +155 -0
  111. kolega_code/cli/messages.py +89 -0
  112. kolega_code/cli/provider_registry.py +96 -0
  113. kolega_code/cli/session_store.py +207 -0
  114. kolega_code/cli/settings.py +87 -0
  115. kolega_code/cli/skills.py +409 -0
  116. kolega_code/cli/slash_commands.py +108 -0
  117. kolega_code/cli/tests/__init__.py +1 -0
  118. kolega_code/cli/tests/test_app.py +4251 -0
  119. kolega_code/cli/tests/test_cli_config.py +171 -0
  120. kolega_code/cli/tests/test_connection.py +26 -0
  121. kolega_code/cli/tests/test_file_index.py +103 -0
  122. kolega_code/cli/tests/test_main.py +455 -0
  123. kolega_code/cli/tests/test_mentions.py +108 -0
  124. kolega_code/cli/tests/test_session_store.py +67 -0
  125. kolega_code/cli/tests/test_settings.py +62 -0
  126. kolega_code/cli/tests/test_skills.py +157 -0
  127. kolega_code/cli/tests/test_slash_commands.py +88 -0
  128. kolega_code/cli/theme.py +180 -0
  129. kolega_code/config.py +154 -0
  130. kolega_code/events.py +202 -0
  131. kolega_code/llm/client.py +300 -0
  132. kolega_code/llm/exceptions.py +285 -0
  133. kolega_code/llm/instrumented_client.py +520 -0
  134. kolega_code/llm/models.py +1368 -0
  135. kolega_code/llm/providers/__init__.py +0 -0
  136. kolega_code/llm/providers/anthropic.py +387 -0
  137. kolega_code/llm/providers/base.py +71 -0
  138. kolega_code/llm/providers/google.py +157 -0
  139. kolega_code/llm/providers/models.py +37 -0
  140. kolega_code/llm/providers/openai.py +363 -0
  141. kolega_code/llm/ratelimit.py +40 -0
  142. kolega_code/llm/specs.py +67 -0
  143. kolega_code/llm/tool_execution_ids.py +18 -0
  144. kolega_code/models/__init__.py +9 -0
  145. kolega_code/models/sandbox_terminal_state.py +47 -0
  146. kolega_code/runtime.py +50 -0
  147. kolega_code/sandbox/README.md +200 -0
  148. kolega_code/sandbox/__init__.py +21 -0
  149. kolega_code/sandbox/async_filesystem.py +475 -0
  150. kolega_code/sandbox/base.py +297 -0
  151. kolega_code/sandbox/browser.py +25 -0
  152. kolega_code/sandbox/event_loop.py +43 -0
  153. kolega_code/sandbox/filesystem.py +341 -0
  154. kolega_code/sandbox/local.py +118 -0
  155. kolega_code/sandbox/serializer.py +175 -0
  156. kolega_code/sandbox/terminal.py +868 -0
  157. kolega_code/sandbox/utils.py +216 -0
  158. kolega_code/services/base.py +255 -0
  159. kolega_code/services/browser.py +444 -0
  160. kolega_code/services/file_system.py +749 -0
  161. kolega_code/services/html.py +221 -0
  162. kolega_code/services/terminal.py +903 -0
  163. kolega_code/tools/__init__.py +22 -0
  164. kolega_code/tools/core.py +33 -0
  165. kolega_code/tools/definitions.py +81 -0
  166. kolega_code/tools/registry.py +73 -0
  167. kolega_code-0.1.0.dist-info/METADATA +157 -0
  168. kolega_code-0.1.0.dist-info/RECORD +171 -0
  169. kolega_code-0.1.0.dist-info/WHEEL +4 -0
  170. kolega_code-0.1.0.dist-info/entry_points.txt +2 -0
  171. kolega_code-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1368 @@
1
+ import base64
2
+ import json
3
+ import logging
4
+ from typing import Any, Dict, List, Optional, Union
5
+
6
+ from google.genai import types as genai_types
7
+
8
+ from .tool_execution_ids import ToolExecutionIdRegistry, new_tool_execution_id
9
+
10
+ # Mapping from type string to class
11
+ CONTENT_BLOCK_CLASSES = {}
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def _remove_trailing_commas(payload: str) -> str:
16
+ """Remove trailing commas before } or ] which frequently cause JSON errors."""
17
+ # Simple, conservative fixes
18
+ payload = payload.replace(",}\n", "}\n").replace(", }", " }")
19
+ payload = payload.replace(",]\n", "]\n").replace(", ]", " ]")
20
+ # Handle edge cases without newlines
21
+ payload = payload.replace(",}", "}")
22
+ payload = payload.replace(",]", "]")
23
+ return payload
24
+
25
+
26
+ def _balance_brackets(payload: str) -> str:
27
+ """If there is an off-by-one bracket mismatch, try to balance it by appending the closing bracket."""
28
+ opens = payload.count("{")
29
+ closes = payload.count("}")
30
+ if opens == closes:
31
+ return payload
32
+ if opens == closes + 1:
33
+ return payload + "}"
34
+ return payload
35
+
36
+
37
+ def safe_parse_tool_arguments(raw: str) -> Dict[str, Any]:
38
+ """Parse OpenAI tool function.arguments into a dict safely.
39
+
40
+ - Tries strict json.loads first
41
+ - Applies minimal, conservative repairs (trim, remove trailing commas, balance one missing brace)
42
+ - Returns a fallback dict with _raw_arguments and _parse_error on failure
43
+ """
44
+ try:
45
+ if not raw:
46
+ return {}
47
+ return json.loads(raw)
48
+ except Exception as first_err:
49
+ repaired = raw.strip()
50
+ repaired = _remove_trailing_commas(repaired)
51
+ repaired = _balance_brackets(repaired)
52
+ try:
53
+ return json.loads(repaired)
54
+ except Exception as second_err:
55
+ # Last resort: do not crash; surface raw args for downstream handling
56
+ snippet = raw if len(raw) <= 200 else raw[:200] + "..."
57
+ logger.warning(
58
+ f"Failed to parse tool arguments as JSON. Using fallback. error1={first_err} error2={second_err} raw_snippet={snippet}"
59
+ )
60
+ return {"_raw_arguments": raw, "_parse_error": "json_decode_error"}
61
+
62
+
63
+
64
+ def register_content_block(cls):
65
+ CONTENT_BLOCK_CLASSES[cls.TYPE_NAME] = cls
66
+ return cls
67
+
68
+
69
+ class ContentBlock:
70
+ """Base class for content blocks in messages"""
71
+
72
+ TYPE_NAME = "base" # Should be overridden by subclasses
73
+
74
+ def __init__(self, type: str, cache_checkpoint: bool = False):
75
+ self.type = type
76
+ self._cache_checkpoint = cache_checkpoint
77
+
78
+ @property
79
+ def cache_checkpoint(self) -> bool:
80
+ return self._cache_checkpoint
81
+
82
+ @cache_checkpoint.setter
83
+ def cache_checkpoint(self, value: bool):
84
+ self._cache_checkpoint = value
85
+
86
+ def to_dict(self) -> Dict[str, Any]:
87
+ """Serializes the content block to a dictionary."""
88
+ raise NotImplementedError("Subclasses must implement to_dict")
89
+
90
+ @classmethod
91
+ def from_dict(cls, data: Dict[str, Any]) -> "ContentBlock":
92
+ """Deserializes a content block from a dictionary."""
93
+ block_type = data.get("type")
94
+ if not block_type or block_type not in CONTENT_BLOCK_CLASSES:
95
+ raise ValueError(f"Unknown or missing content block type: {block_type}")
96
+ target_class = CONTENT_BLOCK_CLASSES[block_type]
97
+ # We assume the target class's from_dict knows how to handle the data
98
+ return target_class.from_dict(data)
99
+
100
+
101
+ @register_content_block
102
+ class TextBlock(ContentBlock):
103
+ """Text content block for messages"""
104
+
105
+ TYPE_NAME = "text"
106
+
107
+ def __init__(self, text: str, cache_checkpoint: bool = False):
108
+ super().__init__(type=self.TYPE_NAME, cache_checkpoint=cache_checkpoint)
109
+ self.text = text
110
+
111
+ def to_dict(self) -> Dict[str, Any]:
112
+ return {
113
+ "type": self.type,
114
+ "text": self.text,
115
+ "cache_checkpoint": self.cache_checkpoint,
116
+ }
117
+
118
+ @classmethod
119
+ def from_dict(cls, data: Dict[str, Any]) -> "TextBlock":
120
+ return cls(text=data["text"], cache_checkpoint=data.get("cache_checkpoint", False))
121
+
122
+ def to_anthropic(self) -> Dict[str, Any]:
123
+ """
124
+ Converts the text block into the Anthropic format.
125
+
126
+ Returns:
127
+ Dict[str, Any]: A dictionary with the structure expected by Anthropic API
128
+ """
129
+ result = {"type": "text", "text": self.text}
130
+
131
+ if self.cache_checkpoint:
132
+ result["cache_control"] = {"type": "ephemeral"}
133
+
134
+ return result
135
+
136
+ def to_openai(self) -> Dict[str, Any]:
137
+ """
138
+ Converts the text block into the OpenAI format.
139
+
140
+ Returns:
141
+ Dict[str, Any]: A dictionary with the structure expected by OpenAI API
142
+ """
143
+ return {"type": "text", "text": self.text}
144
+
145
+ def to_google(self) -> genai_types.Part:
146
+ return genai_types.Part.from_text(text=self.text)
147
+
148
+ def to_markdown(self) -> str:
149
+ """
150
+ Converts the text block into a markdown string.
151
+
152
+ Returns:
153
+ str: The text content formatted as markdown
154
+ """
155
+ return self.text
156
+
157
+
158
+ @register_content_block
159
+ class ImageBlock(ContentBlock):
160
+ TYPE_NAME = "image_url" # Consistent with OpenAI type for simplicity
161
+
162
+ def __init__(self, image_type: str, media_type: str, data: str, cache_checkpoint: bool = False):
163
+ super().__init__(type=self.TYPE_NAME, cache_checkpoint=cache_checkpoint)
164
+
165
+ self.image_type = image_type # e.g., 'base64' or 'url'
166
+ self.media_type = media_type # e.g., 'image/jpeg'
167
+ self.data = data
168
+
169
+ def to_dict(self) -> Dict[str, Any]:
170
+ return {
171
+ "type": self.type,
172
+ "image_type": self.image_type,
173
+ "media_type": self.media_type,
174
+ "data": self.data,
175
+ "cache_checkpoint": self.cache_checkpoint,
176
+ }
177
+
178
+ @classmethod
179
+ def from_dict(cls, data: Dict[str, Any]) -> "ImageBlock":
180
+ return cls(
181
+ image_type=data["image_type"],
182
+ media_type=data["media_type"],
183
+ data=data["data"],
184
+ cache_checkpoint=data.get("cache_checkpoint", False),
185
+ )
186
+
187
+ def to_anthropic(self) -> Dict[str, Any]:
188
+ """
189
+ Converts the image block into the Anthropic format.
190
+
191
+ The method formats the image data according to Anthropic's API requirements,
192
+ including the image type (base64 or url), media type (MIME type), and the
193
+ actual image data.
194
+
195
+ Returns:
196
+ Dict[str, Any]: A dictionary with the structure expected by Anthropic API
197
+ """
198
+ result = {
199
+ "type": "image",
200
+ "source": {"type": self.image_type, "media_type": self.media_type, "data": self.data},
201
+ }
202
+
203
+ if self.cache_checkpoint:
204
+ result["cache_control"] = {"type": "ephemeral"}
205
+
206
+ return result
207
+
208
+ def to_openai(self) -> Dict[str, Any]:
209
+ """
210
+ Converts the image block into the OpenAI format.
211
+
212
+ The method formats the image data according to OpenAI's API requirements,
213
+ including the image type (base64 or url), media type (MIME type), and the
214
+ actual image data.
215
+
216
+ Returns:
217
+ Dict[str, Any]: A dictionary with the structure expected by OpenAI API
218
+ """
219
+ return {
220
+ "type": "image_url",
221
+ "image_url": {
222
+ "url": f"data:{self.media_type};base64,{self.data}" if self.image_type == "base64" else self.data
223
+ },
224
+ }
225
+
226
+ def to_google(self) -> genai_types.Part:
227
+ return genai_types.Part.from_bytes(data=base64.b64decode(self.data), mime_type=self.media_type)
228
+
229
+ def to_markdown(self) -> str:
230
+ """
231
+ Converts the image block into a markdown string with the image embedded.
232
+
233
+ For base64 images, this creates a markdown image tag with the data URI scheme,
234
+ allowing the image to be displayed directly in markdown without external hosting.
235
+
236
+ Returns:
237
+ str: The image formatted as a markdown image tag
238
+ """
239
+
240
+ if self.image_type == "base64":
241
+ return f"data:{self.media_type};base64,{self.data}"
242
+ else:
243
+ return self.data
244
+
245
+
246
+ @register_content_block
247
+ class ThinkingBlock(ContentBlock):
248
+ """Thinking content block for messages"""
249
+
250
+ TYPE_NAME = "thinking"
251
+
252
+ def __init__(self, thinking: str, cache_checkpoint: bool = False, signature: Optional[str] = None):
253
+ super().__init__(type=self.TYPE_NAME, cache_checkpoint=cache_checkpoint)
254
+ self.thinking = thinking
255
+ self.signature = signature
256
+
257
+ def to_dict(self) -> Dict[str, Any]:
258
+ result = {
259
+ "type": self.type,
260
+ "thinking": self.thinking,
261
+ "cache_checkpoint": self.cache_checkpoint,
262
+ }
263
+ if self.signature:
264
+ result["signature"] = self.signature
265
+ return result
266
+
267
+ @classmethod
268
+ def from_dict(cls, data: Dict[str, Any]) -> "ThinkingBlock":
269
+ return cls(
270
+ thinking=data["thinking"],
271
+ cache_checkpoint=data.get("cache_checkpoint", False),
272
+ signature=data.get("signature"),
273
+ )
274
+
275
+ def to_anthropic(self) -> Dict[str, Any]:
276
+ """
277
+ Converts the text block into the Anthropic format.
278
+
279
+ Returns:
280
+ Dict[str, Any]: A dictionary with the structure expected by Anthropic API
281
+ """
282
+ result = {"type": "thinking", "thinking": self.thinking}
283
+ if self.signature:
284
+ result["signature"] = self.signature
285
+
286
+ if self.cache_checkpoint:
287
+ result["cache_control"] = {"type": "ephemeral"}
288
+
289
+ return result
290
+
291
+ def to_openai(self) -> Dict[str, Any]:
292
+ """
293
+ Converts the thinking block into the OpenAI format.
294
+
295
+ Returns:
296
+ Dict[str, Any]: A dictionary with the structure expected by OpenAI API
297
+ """
298
+ # OpenAI doesn't have a direct equivalent for thinking blocks
299
+ # Convert to a text block with formatting to indicate it's thinking
300
+ return {"type": "text", "text": f"*Thinking:*\n{self.thinking}"}
301
+
302
+ def to_google(self) -> genai_types.Part:
303
+ return genai_types.Part.from_text(text=f"*Thinking:*\n{self.thinking}")
304
+
305
+ def to_markdown(self) -> str:
306
+ """
307
+ Converts the thinking block into a markdown string.
308
+
309
+ Returns:
310
+ str: The thinking content formatted as markdown with code block
311
+ """
312
+ return f"*Thinking:*\n\n```\n{self.thinking}\n```"
313
+
314
+
315
+ @register_content_block
316
+ class RedactedThinkingBlock(ContentBlock):
317
+ """Redacted thinking content block for provider-managed encrypted reasoning."""
318
+
319
+ TYPE_NAME = "redacted_thinking"
320
+
321
+ def __init__(self, data: str, cache_checkpoint: bool = False):
322
+ super().__init__(type=self.TYPE_NAME, cache_checkpoint=cache_checkpoint)
323
+ self.data = data
324
+
325
+ def to_dict(self) -> Dict[str, Any]:
326
+ return {
327
+ "type": self.type,
328
+ "data": self.data,
329
+ "cache_checkpoint": self.cache_checkpoint,
330
+ }
331
+
332
+ @classmethod
333
+ def from_dict(cls, data: Dict[str, Any]) -> "RedactedThinkingBlock":
334
+ return cls(data=data["data"], cache_checkpoint=data.get("cache_checkpoint", False))
335
+
336
+ def to_anthropic(self) -> Dict[str, Any]:
337
+ return {"type": "redacted_thinking", "data": self.data}
338
+
339
+ def to_openai(self) -> Dict[str, Any]:
340
+ return {"type": "text", "text": "[Redacted thinking]"}
341
+
342
+ def to_google(self) -> genai_types.Part:
343
+ return genai_types.Part.from_text(text="[Redacted thinking]")
344
+
345
+ def to_markdown(self) -> str:
346
+ return "*Redacted thinking*"
347
+
348
+
349
+ class ToolParameter:
350
+ """Parameter definition for a tool"""
351
+
352
+ def __init__(self, name: str, type: str, description: str, required: bool = False):
353
+ self.name = name
354
+ self.type = type
355
+ self.description = description
356
+ self.required = required
357
+
358
+
359
+ class ToolDefinition(ContentBlock):
360
+ """Unified representation of a tool definition across providers"""
361
+
362
+ def __init__(self, name: str, description: str, parameters: List[ToolParameter], cache_checkpoint: bool = False):
363
+ super().__init__(type="tool_definition", cache_checkpoint=cache_checkpoint)
364
+ self.name = name
365
+ self.description = description
366
+ self.parameters = parameters
367
+
368
+ def to_anthropic(self) -> Dict[str, Any]:
369
+ """
370
+ Converts the tool definition into the Anthropic format.
371
+
372
+ Returns:
373
+ Dict[str, Any]: A dictionary with the structure expected by Anthropic API
374
+ """
375
+ properties = {}
376
+ required = []
377
+
378
+ for param in self.parameters:
379
+ properties[param.name] = {"type": param.type, "description": param.description}
380
+
381
+ if param.required:
382
+ required.append(param.name)
383
+
384
+ result = {
385
+ "name": self.name,
386
+ "description": self.description,
387
+ "input_schema": {"type": "object", "properties": properties, "required": required},
388
+ }
389
+
390
+ if self.cache_checkpoint:
391
+ result["cache_control"] = {"type": "ephemeral"}
392
+
393
+ return result
394
+
395
+ def to_openai(self) -> Dict[str, Any]:
396
+ """
397
+ Converts the tool definition into the OpenAI format.
398
+
399
+ Returns:
400
+ Dict[str, Any]: A dictionary with the structure expected by OpenAI API
401
+ """
402
+ properties = {}
403
+ required = []
404
+
405
+ for param in self.parameters:
406
+ properties[param.name] = {"type": param.type, "description": param.description}
407
+
408
+ if param.required:
409
+ required.append(param.name)
410
+
411
+ return {
412
+ "type": "function",
413
+ "function": {
414
+ "name": self.name,
415
+ "description": self.description,
416
+ "parameters": {"type": "object", "properties": properties, "required": required},
417
+ },
418
+ }
419
+
420
+ def to_google(self) -> genai_types.Tool:
421
+ parameters = {}
422
+ required = []
423
+
424
+ for parameter in self.parameters:
425
+ parameters[parameter.name] = genai_types.Schema(
426
+ type=parameter.type.upper(), description=parameter.description
427
+ )
428
+
429
+ if parameter.required:
430
+ required.append(parameter.name)
431
+
432
+ function_declaration = genai_types.FunctionDeclaration(
433
+ name=self.name,
434
+ description=self.description,
435
+ parameters=genai_types.Schema(type="OBJECT", properties=parameters, required=required),
436
+ )
437
+
438
+ return genai_types.Tool(function_declarations=[function_declaration])
439
+
440
+
441
+ @register_content_block
442
+ class ToolCall(ContentBlock):
443
+ """Unified representation of a tool call across providers"""
444
+
445
+ TYPE_NAME = "tool_call" # Changed from 'tool_use' (Anthropic specific)
446
+
447
+ def __init__(
448
+ self,
449
+ id: str,
450
+ name: str,
451
+ input: Dict[str, Any],
452
+ cache_checkpoint: bool = False,
453
+ execution_id: Optional[str] = None,
454
+ ):
455
+ super().__init__(type=self.TYPE_NAME, cache_checkpoint=cache_checkpoint)
456
+ self.id = id
457
+ self.name = name
458
+ self.input = input
459
+ self.execution_id = execution_id or new_tool_execution_id()
460
+
461
+ def to_dict(self) -> Dict[str, Any]:
462
+ return {
463
+ "type": self.type,
464
+ "id": self.id,
465
+ "name": self.name,
466
+ "input": self.input,
467
+ "cache_checkpoint": self.cache_checkpoint,
468
+ "execution_id": self.execution_id,
469
+ }
470
+
471
+ @classmethod
472
+ def from_dict(cls, data: Dict[str, Any]) -> "ToolCall":
473
+ return cls(
474
+ id=data["id"],
475
+ name=data["name"],
476
+ input=data["input"],
477
+ cache_checkpoint=data.get("cache_checkpoint", False),
478
+ execution_id=data.get("execution_id"),
479
+ )
480
+
481
+ def to_anthropic(self) -> Dict[str, Any]:
482
+ """
483
+ Converts the tool call into the Anthropic format.
484
+
485
+ Returns:
486
+ Dict[str, Any]: A dictionary with the structure expected by Anthropic API
487
+ """
488
+ result = {"type": "tool_use", "id": self.id, "name": self.name, "input": self.input}
489
+
490
+ if self.cache_checkpoint:
491
+ result["cache_control"] = {"type": "ephemeral"}
492
+
493
+ return result
494
+
495
+ def to_openai(self) -> Dict[str, Any]:
496
+ """
497
+ Converts the tool call into the OpenAI format.
498
+
499
+ Returns:
500
+ Dict[str, Any]: A dictionary with the structure expected by OpenAI API
501
+ """
502
+ return {"id": self.id, "type": "function", "function": {"name": self.name, "arguments": json.dumps(self.input)}}
503
+
504
+ def to_google(self) -> genai_types.Part:
505
+ return genai_types.Part(function_call=genai_types.FunctionCall(id=self.id, name=self.name, args=self.input))
506
+
507
+ def to_markdown(self) -> str:
508
+ """
509
+ Formats the tool call as a markdown string for conversation history display.
510
+
511
+ Returns:
512
+ str: A markdown formatted representation of the tool call
513
+ """
514
+ formatted_input = json.dumps(self.input, indent=2)
515
+ return f"**{self.type.replace('_', ' ').capitalize()}**: `{self.name}`\n\n```json\n{formatted_input}\n```\n\n*Tool ID: {self.id}*"
516
+
517
+
518
+ @register_content_block
519
+ class ToolResult(ContentBlock):
520
+ """Unified representation of a tool result across providers"""
521
+
522
+ TYPE_NAME = "tool_result"
523
+
524
+ def __init__(
525
+ self,
526
+ tool_use_id: str,
527
+ content: Union[str, List[ContentBlock]],
528
+ name: str,
529
+ is_error: bool,
530
+ cache_checkpoint: bool = False,
531
+ execution_id: Optional[str] = None,
532
+ ):
533
+ super().__init__(type=self.TYPE_NAME, cache_checkpoint=cache_checkpoint)
534
+
535
+ self.tool_use_id = tool_use_id
536
+ self.content = content # Can be str or list of ContentBlocks
537
+ self.name = name
538
+ self.is_error = bool(is_error)
539
+ self.execution_id = execution_id
540
+
541
+ def to_dict(self) -> Dict[str, Any]:
542
+ serialized_content: Union[str, List[Dict[str, Any]]]
543
+ if isinstance(self.content, str):
544
+ serialized_content = self.content
545
+ elif isinstance(self.content, list):
546
+ serialized_content = [block.to_dict() for block in self.content]
547
+ else:
548
+ # Handle unexpected type, maybe log a warning or error
549
+ serialized_content = str(self.content)
550
+
551
+ result = {
552
+ "type": self.type,
553
+ "tool_use_id": self.tool_use_id,
554
+ "content": serialized_content,
555
+ "name": self.name,
556
+ "is_error": self.is_error,
557
+ "cache_checkpoint": self.cache_checkpoint,
558
+ }
559
+ if self.execution_id:
560
+ result["execution_id"] = self.execution_id
561
+ return result
562
+
563
+ @classmethod
564
+ def from_dict(cls, data: Dict[str, Any]) -> "ToolResult":
565
+ deserialized_content: Union[str, List[ContentBlock]]
566
+ raw_content = data["content"]
567
+
568
+ if isinstance(raw_content, str):
569
+ deserialized_content = raw_content
570
+ elif isinstance(raw_content, list):
571
+ # Recursively deserialize nested content blocks
572
+ deserialized_content = [ContentBlock.from_dict(item) for item in raw_content]
573
+ else:
574
+ # Handle unexpected type
575
+ raise ValueError(f"Unexpected content type during ToolResult deserialization: {type(raw_content)}")
576
+
577
+ return cls(
578
+ tool_use_id=data["tool_use_id"],
579
+ content=deserialized_content,
580
+ name=data["name"],
581
+ is_error=data["is_error"],
582
+ cache_checkpoint=data.get("cache_checkpoint", False),
583
+ execution_id=data.get("execution_id"),
584
+ )
585
+
586
+ def to_anthropic(self) -> Dict[str, Any]:
587
+ """
588
+ Converts the tool result into the Anthropic format.
589
+
590
+ Returns:
591
+ Dict[str, Any]: A dictionary with the structure expected by Anthropic API
592
+ """
593
+ # Handle case where content is a list
594
+ anthropic_content = self.content
595
+ if isinstance(self.content, list):
596
+ anthropic_content = [item.to_anthropic() for item in self.content]
597
+
598
+ # Ensure error results have non-empty content - Anthropic API fails if content is empty
599
+ if self.is_error and not anthropic_content:
600
+ anthropic_content = "Tool execution error"
601
+
602
+ result = {
603
+ "type": "tool_result",
604
+ "tool_use_id": self.tool_use_id,
605
+ "content": anthropic_content,
606
+ "is_error": self.is_error,
607
+ }
608
+
609
+ if self.cache_checkpoint:
610
+ result["cache_control"] = {"type": "ephemeral"}
611
+
612
+ return result
613
+
614
+ def to_openai(self) -> Dict[str, Any]:
615
+ """
616
+ Converts the tool result into the OpenAI format.
617
+
618
+ Returns:
619
+ Dict[str, Any]: A dictionary with the structure expected by OpenAI API
620
+ """
621
+ # Handle case where content is a list
622
+ openai_content = self.content
623
+ if isinstance(self.content, list):
624
+ openai_content = [item.to_openai() for item in self.content]
625
+
626
+ return {"role": "tool", "content": openai_content, "tool_call_id": self.tool_use_id}
627
+
628
+ def to_google(self) -> genai_types.FunctionResponse:
629
+ google_content = self.content
630
+ if isinstance(self.content, list):
631
+ google_content = [item.to_google() for item in self.content]
632
+
633
+ response = {}
634
+
635
+ if self.is_error:
636
+ response["error"] = google_content
637
+ else:
638
+ response["output"] = google_content
639
+
640
+ return genai_types.Part(
641
+ function_response=genai_types.FunctionResponse(id=self.tool_use_id, name=self.name, response=response)
642
+ )
643
+
644
+ def to_markdown(self) -> str:
645
+ """
646
+ Formats the tool result as a markdown string for conversation history display.
647
+
648
+ Returns:
649
+ str: A markdown formatted representation of the tool result
650
+ """
651
+ status = "**Error**" if self.is_error else "**Result**"
652
+
653
+ markdown_content = self.content
654
+ if isinstance(self.content, list):
655
+ markdown_content = "\n\n".join([item.to_markdown() for item in self.content])
656
+
657
+ return f"{status} from tool call (ID: {self.tool_use_id}):\n\n```\n{markdown_content}\n```"
658
+
659
+
660
+ class MessageChunk:
661
+ """Unified representation of a message chunk during streaming"""
662
+
663
+ def __init__(
664
+ self,
665
+ type: str, # "text", "tool_use", "thinking", "tool_use_start", "tool_use_delta", etc.
666
+ text: Optional[str] = None,
667
+ tool_call: Optional[ToolCall] = None,
668
+ thinking: Optional[str] = None,
669
+ tool_call_delta: Optional[Dict[str, Any]] = None,
670
+ ):
671
+ self.type = type
672
+ self.text = text
673
+ self.tool_call = tool_call
674
+ self.thinking = thinking
675
+ self.tool_call_delta = tool_call_delta
676
+
677
+ @classmethod
678
+ def from_anthropic(cls, chunk):
679
+ """
680
+ Converts an Anthropic message chunk to a MessageChunk instance.
681
+
682
+ Args:
683
+ chunk: The Anthropic message chunk from the streaming response
684
+
685
+ Returns:
686
+ MessageChunk: A unified message chunk representation
687
+ """
688
+ if chunk.type == "text":
689
+ return cls(type="text", text=chunk.text)
690
+
691
+ # Handle thinking chunks
692
+ elif chunk.type == "thinking":
693
+ return cls(type="thinking", thinking=chunk.thinking)
694
+
695
+ # Handle tool use start events
696
+ elif chunk.type == "content_block_start" and hasattr(chunk, "content_block"):
697
+ if chunk.content_block.type == "tool_use":
698
+ return cls(
699
+ type="tool_use_start",
700
+ tool_call_delta={"id": chunk.content_block.id, "name": chunk.content_block.name, "input": ""},
701
+ )
702
+
703
+ # Handle tool use delta events (streaming JSON input)
704
+ elif chunk.type == "content_block_delta" and hasattr(chunk, "delta"):
705
+ if chunk.delta.type == "input_json_delta":
706
+ return cls(type="tool_use_delta", tool_call_delta={"input_delta": chunk.delta.partial_json})
707
+ # The Anthropic SDK emits a synthetic `thinking` event for each
708
+ # raw `thinking_delta`; handling both duplicates streamed thinking.
709
+
710
+ # Handle tool use stop events
711
+ elif chunk.type == "content_block_stop":
712
+ return cls(type="tool_use_stop")
713
+
714
+ # Also check for thinking attribute directly (some chunks may have it)
715
+ elif hasattr(chunk, "thinking") and chunk.thinking:
716
+ return cls(type="thinking", thinking=chunk.thinking)
717
+
718
+ # Default empty chunk for other types
719
+ return cls(type="ignore", text="")
720
+
721
+ @classmethod
722
+ def from_openai(cls, chunk):
723
+ """
724
+ Converts an OpenAI ChatCompletion chunk to a MessageChunk instance.
725
+
726
+ Args:
727
+ chunk: The OpenAI ChatCompletion chunk from the streaming response
728
+
729
+ Returns:
730
+ MessageChunk: A unified message chunk representation
731
+ """
732
+ delta = chunk.choices[0].delta
733
+
734
+ # Handle text content
735
+ if delta.content is not None:
736
+ return cls(type="text", text=delta.content)
737
+
738
+ # Default empty chunk if no content or tool calls
739
+ return cls(type="ignore", text="")
740
+
741
+ @classmethod
742
+ def from_google(cls, chunk):
743
+ if chunk.text:
744
+ return cls(type="text", text=chunk.text)
745
+
746
+ # Default empty chunk for other types
747
+ return cls(type="ignore", text="")
748
+
749
+
750
+ class Message:
751
+ """Unified representation of a full message"""
752
+
753
+ def __init__(
754
+ self,
755
+ role: str, # "system", "user", or "assistant"
756
+ content: Union[str, List["ContentBlock"]],
757
+ stop_reason: Optional[str] = None,
758
+ tool_calls: Optional[List[ToolCall]] = None,
759
+ usage_metadata: Optional[Dict[str, Any]] = None,
760
+ ):
761
+ self.role = role
762
+ self.content = content
763
+ self.stop_reason = stop_reason
764
+ self.tool_calls = tool_calls or []
765
+ self.usage_metadata = usage_metadata or {}
766
+
767
+ def get_text_content(self) -> str:
768
+ """
769
+ Returns the concatenated text content from all content blocks.
770
+
771
+ If content is a string, returns it directly.
772
+ If content is a list of ContentBlock objects, extracts and concatenates their text values.
773
+
774
+ Returns:
775
+ str: The concatenated text content
776
+ """
777
+ if isinstance(self.content, str):
778
+ return self.content
779
+ elif isinstance(self.content, list):
780
+ # Extract text from each content block and join them
781
+ return "\n".join(block.text for block in self.content if hasattr(block, "text"))
782
+
783
+ return ""
784
+
785
+ def to_anthropic(self) -> Dict[str, Any]:
786
+ """
787
+ Converts the Message instance to an Anthropic-compatible dictionary format.
788
+
789
+ Returns:
790
+ Dict[str, Any]: A dictionary in Anthropic's expected format
791
+ """
792
+ if isinstance(self.content, str):
793
+ # If content is a string, wrap it in a text block
794
+ content = [{"type": "text", "text": self.content}]
795
+ elif isinstance(self.content, list):
796
+ # If content is a list, convert each item using its to_anthropic method
797
+ content = [item.to_anthropic() for item in self.content]
798
+ else:
799
+ # Fallback for unexpected content type
800
+ content = []
801
+
802
+ return {"role": self.role, "content": content}
803
+
804
+ def to_openai(self) -> Dict[str, Any]:
805
+ """
806
+ Converts the Message instance to an OpenAI-compatible dictionary format.
807
+
808
+ Returns:
809
+ Dict[str, Any]: A dictionary in OpenAI's expected format
810
+ """
811
+ if isinstance(self.content, str):
812
+ content = self.content
813
+ elif isinstance(self.content, list):
814
+ # Exclude tool call and tool result blocks from assistant content; they are handled separately
815
+ non_tool_blocks = [item for item in self.content if not isinstance(item, (ToolCall, ToolResult))]
816
+ content = [item.to_openai() for item in non_tool_blocks]
817
+ else:
818
+ # Fallback for unexpected content type
819
+ content = ""
820
+
821
+ # Handle tool calls if present
822
+ result = {"role": self.role, "content": content}
823
+
824
+ # Extract tool calls from content if they exist
825
+ tool_calls = (
826
+ [item for item in self.content if isinstance(item, ToolCall)] if isinstance(self.content, list) else []
827
+ )
828
+
829
+ if tool_calls:
830
+ result["tool_calls"] = [
831
+ {
832
+ "id": tool_call.id,
833
+ "type": "function",
834
+ "function": {
835
+ "name": tool_call.name,
836
+ "arguments": (
837
+ json.dumps(tool_call.input) if not isinstance(tool_call.input, str) else tool_call.input
838
+ ),
839
+ },
840
+ }
841
+ for tool_call in tool_calls
842
+ ]
843
+
844
+ return result
845
+
846
+ def to_google(self) -> genai_types.Content:
847
+ return genai_types.Content(
848
+ role=self.role if self.role == "user" else "model", parts=[c.to_google() for c in self.content]
849
+ )
850
+
851
+ @classmethod
852
+ def from_anthropic(cls, message, tool_execution_ids: Optional[ToolExecutionIdRegistry] = None):
853
+ """
854
+ Converts an Anthropic message to a Message instance.
855
+
856
+ Args:
857
+ message: The Anthropic message from the response
858
+
859
+ Returns:
860
+ Message: A unified message representation
861
+ """
862
+ tool_execution_ids = tool_execution_ids or ToolExecutionIdRegistry()
863
+ tool_use_blocks = []
864
+ content_blocks = []
865
+
866
+ if hasattr(message, "content"):
867
+ if isinstance(message.content, str):
868
+ # Handle string content by creating a TextBlock
869
+ content_blocks.append(TextBlock(text=message.content))
870
+ elif isinstance(message.content, list):
871
+ # Process structured content
872
+ for block in message.content:
873
+ if hasattr(block, "type"):
874
+ if block.type == "text":
875
+ content_blocks.append(TextBlock(text=block.text))
876
+ elif block.type == "tool_use":
877
+ tool_call = ToolCall(
878
+ id=block.id,
879
+ name=block.name,
880
+ input=block.input,
881
+ execution_id=tool_execution_ids.get_or_create(block.id),
882
+ )
883
+ tool_use_blocks.append(tool_call)
884
+ content_blocks.append(tool_call)
885
+ elif block.type == "thinking":
886
+ content_blocks.append(
887
+ ThinkingBlock(thinking=block.thinking, signature=getattr(block, "signature", None))
888
+ )
889
+ elif block.type == "redacted_thinking":
890
+ content_blocks.append(RedactedThinkingBlock(data=block.data))
891
+
892
+ # Extract usage metadata
893
+ usage_metadata = {}
894
+ if hasattr(message, "usage"):
895
+ usage = message.usage
896
+ usage_metadata = {
897
+ "input_tokens": getattr(usage, "input_tokens", 0),
898
+ "output_tokens": getattr(usage, "output_tokens", 0),
899
+ "cache_read_input_tokens": getattr(usage, "cache_read_input_tokens", 0),
900
+ "cache_write_input_tokens": getattr(usage, "cache_creation_input_tokens", 0),
901
+ "provider": "anthropic",
902
+ }
903
+
904
+ # print(f"Stop reason: {message.stop_reason if hasattr(message, 'stop_reason') else ''}")
905
+
906
+ return cls(
907
+ role=message.role,
908
+ content=content_blocks,
909
+ tool_calls=tool_use_blocks if tool_use_blocks else None,
910
+ stop_reason=message.stop_reason if hasattr(message, "stop_reason") else None,
911
+ usage_metadata=usage_metadata,
912
+ )
913
+
914
+ @classmethod
915
+ def from_openai(cls, message, tool_execution_ids: Optional[ToolExecutionIdRegistry] = None):
916
+ """
917
+ Converts an OpenAI message to a Message instance.
918
+
919
+ Args:
920
+ message: The OpenAI message from the response
921
+
922
+ Returns:
923
+ Message: A unified message representation
924
+ """
925
+ stop_reason_map = {
926
+ "tool_calls": "tool_use",
927
+ "function_call": "tool_use",
928
+ "length": "max_tokens",
929
+ "stop": "end_turn",
930
+ }
931
+
932
+ tool_execution_ids = tool_execution_ids or ToolExecutionIdRegistry()
933
+ content_blocks = []
934
+ tool_use_blocks = []
935
+
936
+ # Handle content
937
+ if hasattr(message, "content") and message.content:
938
+ content_blocks.append(TextBlock(text=message.content))
939
+
940
+ # Handle tool calls
941
+ if hasattr(message, "tool_calls") and message.tool_calls:
942
+ for tool_call in message.tool_calls:
943
+ parsed_args = safe_parse_tool_arguments(tool_call.function.arguments)
944
+ tool_call_obj = ToolCall(
945
+ id=tool_call.id,
946
+ name=tool_call.function.name,
947
+ input=parsed_args,
948
+ execution_id=tool_execution_ids.get_or_create(tool_call.id),
949
+ )
950
+ tool_use_blocks.append(tool_call_obj)
951
+ content_blocks.append(tool_call_obj)
952
+
953
+ # Extract usage metadata - OpenAI provides this on the response, not the message
954
+ # This will need to be set separately after creation
955
+ usage_metadata = {"provider": "openai"}
956
+
957
+ return cls(
958
+ role="assistant",
959
+ content=content_blocks,
960
+ tool_calls=tool_use_blocks if tool_use_blocks else None,
961
+ stop_reason=stop_reason_map[message.finish_reason] if hasattr(message, "finish_reason") else None,
962
+ usage_metadata=usage_metadata,
963
+ )
964
+
965
+ @classmethod
966
+ def from_google(
967
+ cls,
968
+ message: genai_types.GenerateContentResponse,
969
+ tool_execution_ids: Optional[ToolExecutionIdRegistry] = None,
970
+ ):
971
+ stop_reason_map = {
972
+ "MAX_TOKENS": "max_tokens",
973
+ "STOP": "end_turn",
974
+ }
975
+
976
+ tool_execution_ids = tool_execution_ids or ToolExecutionIdRegistry()
977
+ content_blocks = []
978
+ tool_use_blocks = []
979
+
980
+ if message.candidates[0].content and message.candidates[0].content.parts:
981
+ for part in message.candidates[0].content.parts:
982
+ if part.thought:
983
+ content_blocks.append(ThinkingBlock(thinking=part.text))
984
+ elif part.text:
985
+ content_blocks.append(TextBlock(text=part.text))
986
+
987
+ if message.function_calls:
988
+ for function_call in message.function_calls:
989
+ tool_use_blocks.append(
990
+ ToolCall(
991
+ id=function_call.id,
992
+ name=function_call.name,
993
+ input=function_call.args,
994
+ execution_id=tool_execution_ids.get_or_create(function_call.id),
995
+ )
996
+ )
997
+
998
+ mapped_stop_reason = stop_reason_map[message.finish_reason] if hasattr(message, "finish_reason") else None
999
+ if tool_use_blocks:
1000
+ mapped_stop_reason = "tool_use"
1001
+
1002
+ # Extract usage metadata
1003
+ usage_metadata = {}
1004
+ if hasattr(message, "usage_metadata"):
1005
+ usage = message.usage_metadata
1006
+ usage_metadata = {
1007
+ "prompt_token_count": getattr(usage, "prompt_token_count", 0),
1008
+ "candidates_token_count": getattr(usage, "candidates_token_count", 0),
1009
+ "total_token_count": getattr(usage, "total_token_count", 0),
1010
+ "provider": "google",
1011
+ }
1012
+
1013
+ return cls(
1014
+ role="assistant",
1015
+ content=content_blocks,
1016
+ tool_calls=tool_use_blocks if tool_use_blocks else None,
1017
+ stop_reason=mapped_stop_reason,
1018
+ usage_metadata=usage_metadata,
1019
+ )
1020
+
1021
+ @classmethod
1022
+ def from_openai_stream(
1023
+ cls,
1024
+ role: str,
1025
+ content: str,
1026
+ tool_calls: Optional[list] = None,
1027
+ stop_reason: Optional[str] = None,
1028
+ tool_execution_ids: Optional[ToolExecutionIdRegistry] = None,
1029
+ ):
1030
+ """
1031
+ Converts OpenAI message components to a Message instance.
1032
+
1033
+ Args:
1034
+ content: The content text from the OpenAI message
1035
+ tool_calls: List of tool calls from the OpenAI message, if any
1036
+ stop_reason: The reason why the generation stopped
1037
+
1038
+ Returns:
1039
+ Message: A unified message representation
1040
+ """
1041
+ stop_reason_map = {
1042
+ "tool_calls": "tool_use",
1043
+ "function_call": "tool_use",
1044
+ "length": "max_tokens",
1045
+ "stop": "end_turn",
1046
+ }
1047
+
1048
+ tool_execution_ids = tool_execution_ids or ToolExecutionIdRegistry()
1049
+ content_blocks = []
1050
+ tool_use_blocks = []
1051
+
1052
+ # Handle content
1053
+ if content:
1054
+ content_blocks.append(TextBlock(text=content))
1055
+
1056
+ # Handle tool calls
1057
+ if tool_calls:
1058
+ for tool_call in tool_calls.values():
1059
+ parsed_args = safe_parse_tool_arguments(tool_call.function.arguments)
1060
+ tool_call_obj = ToolCall(
1061
+ id=tool_call.id,
1062
+ name=tool_call.function.name,
1063
+ input=parsed_args,
1064
+ execution_id=tool_execution_ids.get_or_create(tool_call.id),
1065
+ )
1066
+ tool_use_blocks.append(tool_call_obj)
1067
+ content_blocks.append(tool_call_obj)
1068
+
1069
+ return cls(
1070
+ role=role,
1071
+ content=content_blocks,
1072
+ tool_calls=tool_use_blocks if tool_use_blocks else None,
1073
+ stop_reason=stop_reason_map[stop_reason] if stop_reason else None,
1074
+ usage_metadata={},
1075
+ )
1076
+
1077
+ @classmethod
1078
+ def from_google_stream(
1079
+ cls,
1080
+ role: str,
1081
+ content: str,
1082
+ tool_calls: Optional[list] = None,
1083
+ stop_reason: Optional[str] = None,
1084
+ tool_execution_ids: Optional[ToolExecutionIdRegistry] = None,
1085
+ ):
1086
+ stop_reason_map = {
1087
+ "MAX_TOKENS": "max_tokens",
1088
+ "STOP": "end_turn",
1089
+ }
1090
+
1091
+ tool_execution_ids = tool_execution_ids or ToolExecutionIdRegistry()
1092
+ content_blocks = []
1093
+ tool_use_blocks = []
1094
+
1095
+ # Handle content
1096
+ if content:
1097
+ content_blocks.append(TextBlock(text=content))
1098
+
1099
+ # Handle tool calls
1100
+ if tool_calls:
1101
+ for tool_call in tool_calls.values():
1102
+ tool_call_obj = ToolCall(
1103
+ id=tool_call.id,
1104
+ name=tool_call.name,
1105
+ input=tool_call.args,
1106
+ execution_id=tool_execution_ids.get_or_create(tool_call.id),
1107
+ )
1108
+ tool_use_blocks.append(tool_call_obj)
1109
+ content_blocks.append(tool_call_obj)
1110
+
1111
+ mapped_stop_reason = stop_reason_map[stop_reason] if stop_reason else None
1112
+ if tool_use_blocks:
1113
+ mapped_stop_reason = "tool_use"
1114
+
1115
+ return cls(
1116
+ role=role,
1117
+ content=content_blocks,
1118
+ tool_calls=tool_use_blocks if tool_use_blocks else None,
1119
+ stop_reason=mapped_stop_reason,
1120
+ usage_metadata={},
1121
+ )
1122
+
1123
+ def to_dict(self) -> Dict[str, Any]:
1124
+ """Serializes the Message object to a dictionary."""
1125
+ serialized_content: Union[str, List[Dict[str, Any]]]
1126
+ if isinstance(self.content, str):
1127
+ serialized_content = self.content
1128
+ elif isinstance(self.content, list):
1129
+ # Use the ContentBlock's to_dict method
1130
+ serialized_content = [block.to_dict() for block in self.content]
1131
+ else:
1132
+ # Or handle error/unexpected type
1133
+ serialized_content = []
1134
+
1135
+ # Note: Tool calls are part of content list now, no separate field needed for dump
1136
+ return {
1137
+ "role": self.role,
1138
+ "content": serialized_content,
1139
+ "stop_reason": self.stop_reason,
1140
+ "usage_metadata": self.usage_metadata,
1141
+ # 'tool_calls' is implicitly handled within the 'content' list
1142
+ }
1143
+
1144
+ @classmethod
1145
+ def from_dict(cls, data: Dict[str, Any]) -> "Message":
1146
+ """Deserializes a Message object from a dictionary."""
1147
+ deserialized_content: Union[str, List[ContentBlock]]
1148
+ raw_content = data.get("content")
1149
+ tool_calls = [] # Initialize tool_calls
1150
+
1151
+ if isinstance(raw_content, str):
1152
+ deserialized_content = raw_content
1153
+ elif isinstance(raw_content, list):
1154
+ # Use the base ContentBlock.from_dict to handle different block types
1155
+ deserialized_content = [ContentBlock.from_dict(item) for item in raw_content]
1156
+ # Extract tool calls specifically for the Message attribute
1157
+ tool_calls = [block for block in deserialized_content if isinstance(block, ToolCall)]
1158
+ else:
1159
+ # Handle missing or unexpected content type
1160
+ deserialized_content = [] # Or raise error
1161
+
1162
+ return cls(
1163
+ role=data["role"],
1164
+ content=deserialized_content,
1165
+ stop_reason=data.get("stop_reason"),
1166
+ tool_calls=tool_calls, # Populate from deserialized content
1167
+ usage_metadata=data.get("usage_metadata", {}),
1168
+ )
1169
+
1170
+ def to_markdown(self) -> str:
1171
+ """
1172
+ Converts the message to a markdown representation for conversation history display.
1173
+
1174
+ Returns:
1175
+ str: A markdown formatted representation of the message
1176
+ """
1177
+ # Start with the role as a header
1178
+ role_display = self.role.capitalize()
1179
+ markdown = f"## {role_display}:\n\n"
1180
+
1181
+ # Process content blocks
1182
+ if isinstance(self.content, str):
1183
+ markdown += self.content + "\n\n"
1184
+ else:
1185
+ for block in self.content:
1186
+ if isinstance(block, ToolResult) and any([isinstance(c, ImageBlock) for c in block.content]):
1187
+ markdown += "**image removed to reduce length**\n\n"
1188
+ else:
1189
+ if hasattr(block, "to_markdown"):
1190
+ markdown += block.to_markdown() + "\n\n"
1191
+ elif hasattr(block, "text"):
1192
+ markdown += block.text + "\n\n"
1193
+ elif hasattr(block, "thinking"):
1194
+ markdown += f"*Thinking:*\n\n```\n{block.thinking}\n```\n\n"
1195
+
1196
+ # Add stop reason if present
1197
+ if self.stop_reason:
1198
+ markdown += f"*Stop reason: {self.stop_reason}*\n\n"
1199
+
1200
+ return markdown.strip()
1201
+
1202
+
1203
+ class MessageHistory(list):
1204
+ def __init__(self, initial_items=None):
1205
+
1206
+ # Validate initial items if provided
1207
+ if initial_items:
1208
+ for item in initial_items:
1209
+ self._validate_item(item)
1210
+ super().__init__(initial_items)
1211
+ else:
1212
+ super().__init__()
1213
+
1214
+ def _validate_item(self, item):
1215
+ if not isinstance(item, Message):
1216
+ raise TypeError(f"Item must be of type {Message.__name__}, got {type(item).__name__}")
1217
+
1218
+ # Override methods that add or replace items
1219
+ def append(self, item):
1220
+ self._validate_item(item)
1221
+ super().append(item)
1222
+
1223
+ def extend(self, iterable):
1224
+ for item in iterable:
1225
+ self._validate_item(item)
1226
+ super().extend(iterable)
1227
+
1228
+ def insert(self, index, item):
1229
+ self._validate_item(item)
1230
+ super().insert(index, item)
1231
+
1232
+ def __setitem__(self, index, item):
1233
+ self._validate_item(item)
1234
+ super().__setitem__(index, item)
1235
+
1236
+ def to_anthropic(self) -> list:
1237
+ return [m.to_anthropic() for m in self]
1238
+
1239
+ def to_openai(self) -> list:
1240
+ processed_messages = []
1241
+ consumed_tool_result_ids = set()
1242
+
1243
+ for message in self:
1244
+ # No list content: pass through
1245
+ if not isinstance(message.content, list):
1246
+ processed_messages.append(message.to_openai())
1247
+ continue
1248
+
1249
+ # Partition ToolResult blocks so they become separate 'tool' messages
1250
+ non_tool_result_blocks = [
1251
+ item for item in message.content if not isinstance(item, ToolResult)
1252
+ ]
1253
+ tool_result_blocks = [
1254
+ item for item in message.content if isinstance(item, ToolResult) and item.tool_use_id not in consumed_tool_result_ids
1255
+ ]
1256
+
1257
+ if tool_result_blocks:
1258
+ # Emit the primary message without tool results
1259
+ temp_message = Message(
1260
+ role=message.role,
1261
+ content=non_tool_result_blocks,
1262
+ stop_reason=message.stop_reason,
1263
+ tool_calls=message.tool_calls,
1264
+ usage_metadata=message.usage_metadata,
1265
+ )
1266
+ temp_payload = temp_message.to_openai()
1267
+
1268
+ # Avoid emitting empty assistant/user messages with neither content nor tool_calls
1269
+ has_content = (
1270
+ isinstance(temp_payload.get("content"), str) and bool(temp_payload.get("content"))
1271
+ ) or (
1272
+ isinstance(temp_payload.get("content"), list) and len(temp_payload.get("content")) > 0
1273
+ )
1274
+ has_tool_calls = bool(temp_payload.get("tool_calls"))
1275
+ if has_content or has_tool_calls:
1276
+ processed_messages.append(temp_payload)
1277
+
1278
+ # Emit each tool_result as a separate tool message
1279
+ for tr in tool_result_blocks:
1280
+ processed_messages.append(tr.to_openai())
1281
+ consumed_tool_result_ids.add(tr.tool_use_id)
1282
+
1283
+ # If assistant included tool_calls, ensure their tool results appear immediately after
1284
+ tool_call_ids = [item.id for item in message.content if isinstance(item, ToolCall)]
1285
+ if tool_call_ids:
1286
+ found_ids = set(tr.tool_use_id for tr in tool_result_blocks)
1287
+ added_ids = set()
1288
+ # Look ahead for missing tool results and emit them now
1289
+ needed = set(tool_call_ids) - found_ids
1290
+ if needed:
1291
+ start_index = list(self).index(message)
1292
+ for look_ahead in self[start_index + 1 :]:
1293
+ if not isinstance(look_ahead.content, list):
1294
+ continue
1295
+ for item in look_ahead.content:
1296
+ if (
1297
+ isinstance(item, ToolResult)
1298
+ and item.tool_use_id in needed
1299
+ and item.tool_use_id not in consumed_tool_result_ids
1300
+ ):
1301
+ processed_messages.append(item.to_openai())
1302
+ consumed_tool_result_ids.add(item.tool_use_id)
1303
+ added_ids.add(item.tool_use_id)
1304
+ if needed.issubset(added_ids | found_ids):
1305
+ break
1306
+
1307
+ # If still missing, emit placeholder tool messages to satisfy API requirements
1308
+ remaining = set(tool_call_ids) - (found_ids | added_ids)
1309
+ for missing_id in remaining:
1310
+ processed_messages.append({"role": "tool", "content": "", "tool_call_id": missing_id})
1311
+ else:
1312
+ # No ToolResult in this message. If it has tool_calls, ensure immediate tool responses.
1313
+ temp_payload = message.to_openai()
1314
+ processed_messages.append(temp_payload)
1315
+
1316
+ tool_calls = temp_payload.get("tool_calls") or []
1317
+ if tool_calls:
1318
+ tool_call_ids = [tc.get("id") for tc in tool_calls if tc.get("id")]
1319
+ added_ids = set()
1320
+ start_index = list(self).index(message)
1321
+
1322
+ # Search ahead for ToolResult blocks matching these ids
1323
+ for look_ahead in self[start_index + 1 :]:
1324
+ if not isinstance(look_ahead.content, list):
1325
+ continue
1326
+ for item in look_ahead.content:
1327
+ if isinstance(item, ToolResult) and item.tool_use_id in tool_call_ids and item.tool_use_id not in consumed_tool_result_ids:
1328
+ processed_messages.append(item.to_openai())
1329
+ consumed_tool_result_ids.add(item.tool_use_id)
1330
+ added_ids.add(item.tool_use_id)
1331
+ if set(tool_call_ids).issubset(added_ids):
1332
+ break
1333
+
1334
+ # If any are still missing, emit placeholder tool messages to satisfy API ordering
1335
+ remaining = [tc_id for tc_id in tool_call_ids if tc_id not in added_ids]
1336
+ for missing_id in remaining:
1337
+ processed_messages.append({"role": "tool", "content": "", "tool_call_id": missing_id})
1338
+
1339
+ return processed_messages
1340
+
1341
+ def to_google(self) -> list:
1342
+ processed_messages = []
1343
+
1344
+ for message in self:
1345
+ # If the message content is not a list of ToolResult objects, add it directly
1346
+ if not isinstance(message.content, list) or not all(
1347
+ isinstance(item, ToolResult) for item in message.content
1348
+ ):
1349
+ processed_messages.append(message.to_google())
1350
+ else:
1351
+ tool_response_message = genai_types.Content(
1352
+ role="tool", parts=[item.to_google() for item in message.content]
1353
+ )
1354
+
1355
+ processed_messages.append(tool_response_message)
1356
+
1357
+ return processed_messages
1358
+
1359
+ def get_markdown_conversation(self) -> str:
1360
+ markdown_output = []
1361
+ markdown_output.append("# Conversation\n")
1362
+
1363
+ for message in self:
1364
+ markdown_output.append(message.to_markdown())
1365
+
1366
+ conversation = "\n".join(markdown_output)
1367
+
1368
+ return conversation