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.
- kolega_code/__init__.py +151 -0
- kolega_code/agent/__init__.py +42 -0
- kolega_code/agent/baseagent.py +998 -0
- kolega_code/agent/browseragent.py +123 -0
- kolega_code/agent/coder.py +157 -0
- kolega_code/agent/common.py +41 -0
- kolega_code/agent/compression.py +81 -0
- kolega_code/agent/context.py +112 -0
- kolega_code/agent/conversation.py +408 -0
- kolega_code/agent/generalagent.py +146 -0
- kolega_code/agent/investigationagent.py +123 -0
- kolega_code/agent/planningagent.py +187 -0
- kolega_code/agent/prompt_provider.py +196 -0
- kolega_code/agent/prompt_templates/agents/browser.j2 +102 -0
- kolega_code/agent/prompt_templates/agents/coder_cli_mode.j2 +127 -0
- kolega_code/agent/prompt_templates/agents/general.j2 +68 -0
- kolega_code/agent/prompt_templates/agents/investigation.j2 +72 -0
- kolega_code/agent/prompt_templates/common/frontend_guidance.md +36 -0
- kolega_code/agent/prompt_templates/common/kolega_md_instructions.md +14 -0
- kolega_code/agent/prompt_templates/environment_variables/workspace_env_vars.md +11 -0
- kolega_code/agent/prompt_templates/template_guidance/expo-template.md +379 -0
- kolega_code/agent/prompt_templates/template_guidance/html-website-template.md +3 -0
- kolega_code/agent/prompt_templates/template_guidance/mern-stack-template.md +3 -0
- kolega_code/agent/prompt_templates/template_guidance/react-vite-shadcdn-template.md +182 -0
- kolega_code/agent/prompts.py +192 -0
- kolega_code/agent/tests/__init__.py +0 -0
- kolega_code/agent/tests/llm/__init__.py +0 -0
- kolega_code/agent/tests/llm/test_anthropic_token_counting.py +633 -0
- kolega_code/agent/tests/llm/test_billing_openai_cache.py +74 -0
- kolega_code/agent/tests/llm/test_client.py +773 -0
- kolega_code/agent/tests/llm/test_dashscope_mapping.py +32 -0
- kolega_code/agent/tests/llm/test_error_boundary.py +322 -0
- kolega_code/agent/tests/llm/test_exceptions.py +249 -0
- kolega_code/agent/tests/llm/test_instrumented_client.py +536 -0
- kolega_code/agent/tests/llm/test_instrumented_client_integration.py +547 -0
- kolega_code/agent/tests/llm/test_langfuse_normalization.py +39 -0
- kolega_code/agent/tests/llm/test_model_specs.py +17 -0
- kolega_code/agent/tests/llm/test_openai_cached_tokens.py +58 -0
- kolega_code/agent/tests/llm/test_openai_cached_tokens_stream.py +74 -0
- kolega_code/agent/tests/llm/test_openai_message_conversion.py +30 -0
- kolega_code/agent/tests/llm/test_openai_token_counting.py +687 -0
- kolega_code/agent/tests/llm/test_tool_execution_ids.py +193 -0
- kolega_code/agent/tests/services/__init__.py +1 -0
- kolega_code/agent/tests/services/test_browser.py +447 -0
- kolega_code/agent/tests/services/test_browser_parity.py +353 -0
- kolega_code/agent/tests/services/test_file_system.py +699 -0
- kolega_code/agent/tests/services/test_sandbox_terminal_input.py +98 -0
- kolega_code/agent/tests/services/test_terminal.py +154 -0
- kolega_code/agent/tests/services/test_terminal_command_tracking.py +385 -0
- kolega_code/agent/tests/services/test_terminal_state_serializer.py +262 -0
- kolega_code/agent/tests/test_agent_tools_inventory.py +267 -0
- kolega_code/agent/tests/test_base_agent.py +1942 -0
- kolega_code/agent/tests/test_coder_attachments.py +330 -0
- kolega_code/agent/tests/test_coder_prompt_extensions.py +61 -0
- kolega_code/agent/tests/test_commands.py +179 -0
- kolega_code/agent/tests/test_duplicate_tool_results.py +556 -0
- kolega_code/agent/tests/test_empty_message_handling.py +48 -0
- kolega_code/agent/tests/test_general_agent.py +242 -0
- kolega_code/agent/tests/test_html.py +320 -0
- kolega_code/agent/tests/test_parallel_tool_calls.py +291 -0
- kolega_code/agent/tests/test_planning_agent.py +227 -0
- kolega_code/agent/tests/test_prompt_provider.py +271 -0
- kolega_code/agent/tests/test_tool_registry.py +102 -0
- kolega_code/agent/tests/test_tools.py +549 -0
- kolega_code/agent/tests/tool_backend/__init__.py +0 -0
- kolega_code/agent/tests/tool_backend/test_agent_tool.py +356 -0
- kolega_code/agent/tests/tool_backend/test_base_tool.py +147 -0
- kolega_code/agent/tests/tool_backend/test_browser_tool.py +335 -0
- kolega_code/agent/tests/tool_backend/test_build_tool.py +93 -0
- kolega_code/agent/tests/tool_backend/test_create_file_tool.py +115 -0
- kolega_code/agent/tests/tool_backend/test_glob_tool.py +196 -0
- kolega_code/agent/tests/tool_backend/test_glob_tool_sandbox_parity.py +230 -0
- kolega_code/agent/tests/tool_backend/test_list_directory_tool.py +292 -0
- kolega_code/agent/tests/tool_backend/test_read_file_tool.py +173 -0
- kolega_code/agent/tests/tool_backend/test_replace_entire_file_tool.py +115 -0
- kolega_code/agent/tests/tool_backend/test_replace_lines_tool.py +141 -0
- kolega_code/agent/tests/tool_backend/test_search_and_replace_tool.py +174 -0
- kolega_code/agent/tests/tool_backend/test_search_codebase_tool.py +228 -0
- kolega_code/agent/tests/tool_backend/test_terminal_tool.py +482 -0
- kolega_code/agent/tests/tool_backend/test_think_hard_integration.py +189 -0
- kolega_code/agent/tests/tool_backend/test_think_hard_streaming.py +445 -0
- kolega_code/agent/tests/tool_backend/test_web_fetch_tool.py +194 -0
- kolega_code/agent/tool_backend/agent_tool.py +414 -0
- kolega_code/agent/tool_backend/apply_edit_tool.py +98 -0
- kolega_code/agent/tool_backend/apply_patch_tool.py +514 -0
- kolega_code/agent/tool_backend/base_tool.py +217 -0
- kolega_code/agent/tool_backend/browser_tool.py +271 -0
- kolega_code/agent/tool_backend/build_tool.py +93 -0
- kolega_code/agent/tool_backend/create_file_tool.py +52 -0
- kolega_code/agent/tool_backend/glob_tool.py +323 -0
- kolega_code/agent/tool_backend/list_directory_tool.py +300 -0
- kolega_code/agent/tool_backend/memory_tool.py +79 -0
- kolega_code/agent/tool_backend/read_file_tool.py +119 -0
- kolega_code/agent/tool_backend/replace_entire_file_tool.py +40 -0
- kolega_code/agent/tool_backend/replace_lines_tool.py +97 -0
- kolega_code/agent/tool_backend/search_and_replace_tool.py +146 -0
- kolega_code/agent/tool_backend/search_codebase_tool.py +377 -0
- kolega_code/agent/tool_backend/streaming_tool.py +47 -0
- kolega_code/agent/tool_backend/terminal_tool.py +643 -0
- kolega_code/agent/tool_backend/think_hard_tool.py +211 -0
- kolega_code/agent/tool_backend/web_fetch_tool.py +205 -0
- kolega_code/agent/tools.py +1704 -0
- kolega_code/agent/utils/commands.py +94 -0
- kolega_code/cli/__init__.py +1 -0
- kolega_code/cli/app.py +2756 -0
- kolega_code/cli/config.py +280 -0
- kolega_code/cli/connection.py +49 -0
- kolega_code/cli/file_index.py +147 -0
- kolega_code/cli/main.py +564 -0
- kolega_code/cli/mentions.py +155 -0
- kolega_code/cli/messages.py +89 -0
- kolega_code/cli/provider_registry.py +96 -0
- kolega_code/cli/session_store.py +207 -0
- kolega_code/cli/settings.py +87 -0
- kolega_code/cli/skills.py +409 -0
- kolega_code/cli/slash_commands.py +108 -0
- kolega_code/cli/tests/__init__.py +1 -0
- kolega_code/cli/tests/test_app.py +4251 -0
- kolega_code/cli/tests/test_cli_config.py +171 -0
- kolega_code/cli/tests/test_connection.py +26 -0
- kolega_code/cli/tests/test_file_index.py +103 -0
- kolega_code/cli/tests/test_main.py +455 -0
- kolega_code/cli/tests/test_mentions.py +108 -0
- kolega_code/cli/tests/test_session_store.py +67 -0
- kolega_code/cli/tests/test_settings.py +62 -0
- kolega_code/cli/tests/test_skills.py +157 -0
- kolega_code/cli/tests/test_slash_commands.py +88 -0
- kolega_code/cli/theme.py +180 -0
- kolega_code/config.py +154 -0
- kolega_code/events.py +202 -0
- kolega_code/llm/client.py +300 -0
- kolega_code/llm/exceptions.py +285 -0
- kolega_code/llm/instrumented_client.py +520 -0
- kolega_code/llm/models.py +1368 -0
- kolega_code/llm/providers/__init__.py +0 -0
- kolega_code/llm/providers/anthropic.py +387 -0
- kolega_code/llm/providers/base.py +71 -0
- kolega_code/llm/providers/google.py +157 -0
- kolega_code/llm/providers/models.py +37 -0
- kolega_code/llm/providers/openai.py +363 -0
- kolega_code/llm/ratelimit.py +40 -0
- kolega_code/llm/specs.py +67 -0
- kolega_code/llm/tool_execution_ids.py +18 -0
- kolega_code/models/__init__.py +9 -0
- kolega_code/models/sandbox_terminal_state.py +47 -0
- kolega_code/runtime.py +50 -0
- kolega_code/sandbox/README.md +200 -0
- kolega_code/sandbox/__init__.py +21 -0
- kolega_code/sandbox/async_filesystem.py +475 -0
- kolega_code/sandbox/base.py +297 -0
- kolega_code/sandbox/browser.py +25 -0
- kolega_code/sandbox/event_loop.py +43 -0
- kolega_code/sandbox/filesystem.py +341 -0
- kolega_code/sandbox/local.py +118 -0
- kolega_code/sandbox/serializer.py +175 -0
- kolega_code/sandbox/terminal.py +868 -0
- kolega_code/sandbox/utils.py +216 -0
- kolega_code/services/base.py +255 -0
- kolega_code/services/browser.py +444 -0
- kolega_code/services/file_system.py +749 -0
- kolega_code/services/html.py +221 -0
- kolega_code/services/terminal.py +903 -0
- kolega_code/tools/__init__.py +22 -0
- kolega_code/tools/core.py +33 -0
- kolega_code/tools/definitions.py +81 -0
- kolega_code/tools/registry.py +73 -0
- kolega_code-0.1.0.dist-info/METADATA +157 -0
- kolega_code-0.1.0.dist-info/RECORD +171 -0
- kolega_code-0.1.0.dist-info/WHEEL +4 -0
- kolega_code-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|