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,998 @@
1
+ import asyncio
2
+ import contextvars
3
+ import logging
4
+ import os
5
+ import re
6
+ import uuid
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Any, AsyncGenerator, Dict, List, Optional
10
+
11
+ from .common import LogMixin
12
+ from .compression import HistoryCompressor
13
+ from kolega_code.config import AgentConfig, ModelProvider
14
+ from kolega_code.events import AgentConnectionManager
15
+ from .context import AgentContext, AgentServices, Telemetry, WorkspaceInfo
16
+ from .conversation import Conversation
17
+ from kolega_code.events import AgentEventEmitter
18
+ from kolega_code.llm.exceptions import (
19
+ LLMContextWindowExceededError,
20
+ LLMError,
21
+ LLMInternalServerError,
22
+ LLMRateLimitError,
23
+ map_to_llm_error,
24
+ )
25
+ from kolega_code.llm.models import ImageBlock, Message, MessageHistory, TextBlock, ToolCall, ToolResult
26
+ from kolega_code.llm.providers.models import TokenCount
27
+ from kolega_code.llm.specs import get_model_specs
28
+ from .prompt_provider import PromptProvider, AgentMode, PromptContext, PromptExtension
29
+ from kolega_code.services.base import TerminalManager, BrowserManager
30
+ from kolega_code.services.file_system import FileSystem
31
+ from .tools import ToolCollection
32
+ from kolega_code.tools import ToolError
33
+ from .utils.commands import CommandProcessor
34
+ from langfuse import Langfuse
35
+
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+
40
+ class BaseAgent(LogMixin):
41
+ """
42
+ Base class for all AI agents in the system.
43
+
44
+ BaseAgent owns the canonical agent loop and composes the pieces that do
45
+ the real work: a Conversation (history and its invariants), a
46
+ HistoryCompressor (context-budget management), and an AgentEventEmitter
47
+ (event construction and broadcast). Subclasses configure tools and the
48
+ system prompt, and customize behavior through the documented hook methods.
49
+ """
50
+
51
+ agent_name = "base-agent" # you should never see this
52
+ history_compression_threshold = 0.8
53
+ # Cap on concurrently executing tool calls within one batch (each dispatched
54
+ # sub-agent runs its own multi-turn LLM loop, so an unbounded fan-out would
55
+ # multiply token spend and shared-resource pressure).
56
+ PARALLEL_TOOL_LIMIT = 8
57
+ long_content_tool_calls = ["create_file", "replace_entire_file"]
58
+ max_tool_result_chars_in_history = 100_000
59
+ skill_content_pattern = re.compile(r'<skill_content name="[^"]+">')
60
+ deepseek_image_unsupported_message = (
61
+ "DeepSeek V4 Pro does not support image input via the DeepSeek API. "
62
+ "Remove the image or switch to a vision-capable model for this request."
63
+ )
64
+
65
+ def __init__(
66
+ self,
67
+ project_path: str | Path | None = None,
68
+ workspace_id: Optional[str] = None,
69
+ thread_id: Optional[str] = None,
70
+ connection_manager: Optional[AgentConnectionManager] = None,
71
+ config: Optional[AgentConfig] = None,
72
+ sub_agent: bool = False,
73
+ filesystem: Optional[FileSystem] = None,
74
+ terminal_manager: Optional[TerminalManager] = None,
75
+ browser_manager: Optional[BrowserManager] = None,
76
+ langfuse_client: Optional[Langfuse] = None,
77
+ user_id: Optional[str] = None,
78
+ user_email: Optional[str] = None,
79
+ project_template_slug: Optional[str] = None,
80
+ protected_files: Optional[List[str]] = None,
81
+ agent_mode: Optional[AgentMode] = None,
82
+ workspace_env_var_descriptions: Optional[Dict[str, str]] = None,
83
+ workspace_memories: Optional[List[str]] = None,
84
+ prompt_provider: Optional[PromptProvider] = None,
85
+ prompt_extensions: Optional[List[PromptExtension]] = None,
86
+ tool_extensions: Optional[List[Any]] = None,
87
+ usage_recorder: Optional[Any] = None,
88
+ sub_agent_recorder: Optional[Any] = None,
89
+ context: Optional[AgentContext] = None,
90
+ ) -> None:
91
+ """
92
+ Initialize a new BaseAgent instance.
93
+
94
+ Preferred: pass a fully-built ``context`` (AgentContext). The flat
95
+ keyword signature remains supported and is converted internally.
96
+
97
+ Args:
98
+ project_path: File system path to the project root directory
99
+ workspace_id: Unique identifier for the workspace
100
+ thread_id: Unique identifier for the thread
101
+ connection_manager: Connection manager for agent communication
102
+ config: Agent configuration
103
+ sub_agent: Whether this is a sub-agent
104
+ filesystem: Optional filesystem implementation. If None, creates LocalFileSystem with project_path as root
105
+ terminal_manager: Optional terminal manager implementation. If None, creates LocalTerminalManager
106
+ browser_manager: Optional browser manager implementation. If None, creates PlaywrightBrowserManager
107
+ langfuse_client: Optional Langfuse client for LLM observability
108
+ user_id: Optional ID of user who created this job
109
+ user_email: Optional email of user who created this job
110
+ project_template_slug: Optional slug of the project template being used
111
+ protected_files: Optional list of file basenames protected from edits in vibe mode
112
+ agent_mode: Optional agent mode (e.g., AgentMode.VIBE or AgentMode.CODE for CoderAgent)
113
+ workspace_env_var_descriptions: Optional mapping of workspace env var names to descriptions
114
+ workspace_memories: Optional list of workspace memories to inject into prompts
115
+ prompt_provider: Optional host-configured prompt provider
116
+ prompt_extensions: Host-provided prompt sections for app-specific context
117
+ tool_extensions: Host-provided tool providers for app-specific tools
118
+ usage_recorder: Optional callback for recording normalized LLM usage
119
+ sub_agent_recorder: Optional callback for persisting sub-agent conversation state
120
+ context: Pre-built AgentContext; takes precedence over the flat keywords
121
+ """
122
+ if context is None:
123
+ if project_path is None or workspace_id is None or thread_id is None:
124
+ raise TypeError(
125
+ "BaseAgent requires either an AgentContext or project_path/workspace_id/thread_id"
126
+ )
127
+
128
+ workspace = WorkspaceInfo(
129
+ project_path=Path(project_path) if isinstance(project_path, str) else project_path,
130
+ workspace_id=workspace_id,
131
+ thread_id=thread_id,
132
+ project_template_slug=project_template_slug,
133
+ protected_files=protected_files or [],
134
+ env_var_descriptions=workspace_env_var_descriptions or {},
135
+ memories=workspace_memories or [],
136
+ )
137
+
138
+ defaults = AgentServices.local(workspace, connection_manager)
139
+ services = AgentServices(
140
+ filesystem=filesystem or defaults.filesystem,
141
+ terminal_manager=terminal_manager or defaults.terminal_manager,
142
+ browser_manager=browser_manager or defaults.browser_manager,
143
+ )
144
+
145
+ context = AgentContext(
146
+ workspace=workspace,
147
+ config=config,
148
+ connection_manager=connection_manager,
149
+ services=services,
150
+ telemetry=Telemetry(
151
+ langfuse_client=langfuse_client,
152
+ user_id=user_id,
153
+ user_email=user_email,
154
+ usage_recorder=usage_recorder,
155
+ sub_agent_recorder=sub_agent_recorder,
156
+ ),
157
+ agent_mode=agent_mode,
158
+ prompt_provider=prompt_provider,
159
+ prompt_extensions=prompt_extensions or [],
160
+ tool_extensions=tool_extensions or [],
161
+ )
162
+ elif prompt_provider is not None:
163
+ context.prompt_provider = prompt_provider
164
+
165
+ self.context = context
166
+
167
+ # Flat attributes kept for compatibility with subclasses, tools, and hosts.
168
+ self.project_path = context.workspace.project_path
169
+ self.workspace_id = context.workspace.workspace_id
170
+ self.thread_id = context.workspace.thread_id
171
+ self.connection_manager = context.connection_manager
172
+ self.config = context.config
173
+ self.filesystem = context.services.filesystem
174
+ self.terminal_manager = context.services.terminal_manager
175
+ self.browser_manager = context.services.browser_manager
176
+ self.langfuse_client = context.telemetry.langfuse_client
177
+ self.user_id = context.telemetry.user_id
178
+ self.user_email = context.telemetry.user_email
179
+ self.project_template_slug = context.workspace.project_template_slug
180
+ self.protected_files = context.workspace.protected_files
181
+ self.agent_mode = context.agent_mode
182
+ self.workspace_env_var_descriptions = context.workspace.env_var_descriptions
183
+ self.workspace_memories = context.workspace.memories
184
+ self.prompt_extensions = context.prompt_extensions
185
+ self.tool_extensions = context.tool_extensions
186
+ self.usage_recorder = context.telemetry.usage_recorder
187
+ self.sub_agent_recorder = context.telemetry.sub_agent_recorder
188
+
189
+ self.available_ports = "9001-9999"
190
+
191
+ # Validate that the project path exists and is a directory using the filesystem
192
+ if not self.filesystem.exists("."):
193
+ raise ValueError(f"Project path does not exist: {self.project_path}")
194
+ if not self.filesystem.is_dir("."):
195
+ raise ValueError(f"Project path is not a directory: {self.project_path}")
196
+
197
+ self.prompt_provider = context.prompt_provider or PromptProvider()
198
+
199
+ self.conversation = Conversation(max_tool_result_chars=self.max_tool_result_chars_in_history)
200
+ self.conversation.skill_content_pattern = self.skill_content_pattern
201
+ self.compressor = HistoryCompressor(threshold=self.history_compression_threshold)
202
+ self.emitter = AgentEventEmitter(
203
+ connection_manager=self.connection_manager,
204
+ workspace_id=self.workspace_id,
205
+ thread_id=self.thread_id,
206
+ sender=self.agent_name,
207
+ sub_agent_info_provider=self._sub_agent_info,
208
+ )
209
+
210
+ model_specs = get_model_specs(self.config.long_context_config.provider, self.config.long_context_config.model)
211
+ self.model_context_length = model_specs["context_length"]
212
+ self.model_completion_tokens = model_specs["max_completion_tokens"]
213
+ self.model_default_temperature = model_specs.get("default_temperature", 1.0)
214
+
215
+ self.llm = context.create_llm_client(agent_name=self.agent_name)
216
+
217
+ # Tool collection must be initialized by subclass with appropriate configuration
218
+ # (e.g., read_only, browser_only, custom tool_config, etc.)
219
+ self.tool_collection = None
220
+
221
+ self.command_processor = CommandProcessor(self)
222
+
223
+ self.sub_agent = sub_agent
224
+ # Per-instance ContextVars so concurrent tool executions (asyncio.gather in
225
+ # process_tool_calls) each see their own current tool call IDs. Instance-level
226
+ # (rather than module-level) vars also keep a nested sub-agent, which executes
227
+ # tools within the same asyncio task as the parent's dispatch call, from
228
+ # clobbering the parent's values.
229
+ self._current_tool_call_id_var = contextvars.ContextVar("current_tool_call_id", default=None)
230
+ self._current_tool_execution_id_var = contextvars.ContextVar("current_tool_execution_id", default=None)
231
+ self._current_provider_tool_call_id_var = contextvars.ContextVar("current_provider_tool_call_id", default=None)
232
+ self.parent_tool_call_id = None # Parent tool call ID when running as sub-agent
233
+ self.conversation_id = None # Sub-agent conversation ID
234
+ self.sub_agent_context = None # Dispatch metadata (agent_id, task) set by AgentTool
235
+
236
+ # ------------------------------------------------------------------
237
+ # Conversation delegation
238
+ #
239
+ # self.conversation owns the message history; these wrappers preserve the
240
+ # established BaseAgent surface for subclasses and hosts.
241
+ # ------------------------------------------------------------------
242
+
243
+ @property
244
+ def history(self) -> MessageHistory:
245
+ return self.conversation.history
246
+
247
+ @history.setter
248
+ def history(self, value) -> None:
249
+ self.conversation.history = value if isinstance(value, MessageHistory) else MessageHistory(list(value))
250
+
251
+ @property
252
+ def last_compression_index(self) -> Optional[int]:
253
+ return self.conversation.last_compression_index
254
+
255
+ @last_compression_index.setter
256
+ def last_compression_index(self, value: Optional[int]) -> None:
257
+ self.conversation.last_compression_index = value
258
+
259
+ def append_user_message(self, content) -> None:
260
+ """
261
+ Safely append a user message to history, fixing any incomplete tool calls first.
262
+
263
+ Args:
264
+ content: Either a string (converted to TextBlock) or list of ContentBlocks
265
+ """
266
+ self.conversation.append_user(content)
267
+
268
+ def append_assistant_message(self, message: Message) -> None:
269
+ """
270
+ Safely append an assistant message to history.
271
+
272
+ Args:
273
+ message: The assistant message to append
274
+ """
275
+ self.conversation.append_assistant(message)
276
+
277
+ def extend_history(self, messages: List[Message]) -> None:
278
+ """
279
+ Safely extend history with multiple messages, validating the sequence.
280
+
281
+ Args:
282
+ messages: List of messages to append
283
+ """
284
+ self.conversation.extend(messages)
285
+
286
+ def get_effective_history_for_llm(self) -> MessageHistory:
287
+ """
288
+ Return the subset of history to send to the LLM:
289
+ - If compressed: [summary] + all messages after the compression boundary (excluding the summary itself)
290
+ - Else: the full history
291
+ """
292
+ return self.conversation.effective_history()
293
+
294
+ def fix_incomplete_tool_calls(self, messages: List[Message]) -> List[Message]:
295
+ """
296
+ Fix incomplete tool call sequences by adding placeholder tool_result blocks
297
+ for any orphaned tool_use blocks.
298
+
299
+ Args:
300
+ messages: List of messages to validate and fix
301
+
302
+ Returns:
303
+ List[Message]: Fixed messages with placeholder tool results added where needed
304
+ """
305
+ return self.conversation.repaired(messages)
306
+
307
+ def mark_cache_checkpoint(self) -> None:
308
+ """
309
+ Mark the last message in history for caching and remove cache_control from all other messages.
310
+
311
+ This ensures that only the most recent message is cached, preventing redundant caching
312
+ of older messages in the conversation history.
313
+ """
314
+ self.conversation.mark_cache_checkpoint()
315
+
316
+ def dump_message_history(self) -> List[Dict[str, Any]]:
317
+ """Serializes the message history into a list of dictionaries using custom methods."""
318
+ return self.conversation.dump()
319
+
320
+ def restore_message_history(self, serialized_history: List[Dict[str, Any]]) -> None:
321
+ """Restores the message history from a list of dictionaries using custom methods."""
322
+ self.conversation.restore(serialized_history)
323
+
324
+ def _sanitize_oversized_tool_results(self) -> int:
325
+ return self.conversation.sanitize_oversized_tool_results()
326
+
327
+ def _is_history_valid_for_anthropic(self, messages: List[Message] = None) -> bool:
328
+ """
329
+ Check if the message history is valid for Anthropic API.
330
+ Every tool_use block must be followed by a tool_result block.
331
+ """
332
+ return self.conversation.is_valid_for_anthropic(messages)
333
+
334
+ def _is_protected_skill_content(self, message: Message) -> bool:
335
+ return self.conversation.is_protected(message)
336
+
337
+ def _needs_tool_call_fix(self) -> bool:
338
+ """Check if the last message has incomplete tool calls."""
339
+ return self.conversation.needs_tool_call_fix()
340
+
341
+ # ------------------------------------------------------------------
342
+ # Prompt context
343
+ # ------------------------------------------------------------------
344
+
345
+ def build_prompt_context(self) -> PromptContext:
346
+ """Build PromptContext from agent state."""
347
+ import platform
348
+
349
+ # Check if it's a git repository
350
+ is_git_repo = self.filesystem.exists(".git") and self.filesystem.is_dir(".git")
351
+
352
+ # Load KOLEGA.md content if it exists
353
+ kolega_md_content = ""
354
+ if self.filesystem.exists("KOLEGA.md"):
355
+ try:
356
+ kolega_md_content = self.filesystem.read("KOLEGA.md")
357
+ except Exception:
358
+ # If there's an error reading the file, use empty string
359
+ kolega_md_content = ""
360
+
361
+ return PromptContext(
362
+ system_name=os.getenv("KOLEGA_CODE_SYSTEM_NAME", "Kolega Code"),
363
+ project_path=str(self.project_path),
364
+ is_git_repo=is_git_repo,
365
+ platform=platform.system(),
366
+ date_today=datetime.now().strftime("%Y-%m-%d"),
367
+ model_name=self.config.long_context_config.model,
368
+ available_ports=self.available_ports,
369
+ kolega_md=kolega_md_content,
370
+ workspace_id=self.workspace_id,
371
+ workspace_environment_variables=self.workspace_env_var_descriptions,
372
+ memories=self.workspace_memories,
373
+ )
374
+
375
+ # ------------------------------------------------------------------
376
+ # Attachments
377
+ # ------------------------------------------------------------------
378
+
379
+ def _unsupported_attachment_message(self, attachments: Optional[List[Dict[str, Any]]]) -> Optional[str]:
380
+ provider = getattr(
381
+ self.config.long_context_config.provider,
382
+ "value",
383
+ self.config.long_context_config.provider,
384
+ )
385
+ if provider != ModelProvider.DEEPSEEK.value:
386
+ return None
387
+
388
+ if any(attachment.get("type") == "image" for attachment in attachments or []):
389
+ return self.deepseek_image_unsupported_message
390
+
391
+ return None
392
+
393
+ def _attachment_blocks(self, attachments: Optional[List[Dict[str, Any]]]) -> List[Any]:
394
+ """Convert attachment payloads into content blocks for a user message."""
395
+ blocks: List[Any] = []
396
+ for attachment in attachments or []:
397
+ attachment_type = attachment.get("type")
398
+ if attachment_type == "image":
399
+ blocks.append(
400
+ ImageBlock(
401
+ image_type="base64",
402
+ media_type=attachment.get("media_type", "image/png"),
403
+ data=attachment["data"],
404
+ )
405
+ )
406
+ elif attachment_type == "file":
407
+ path = attachment.get("path", "")
408
+ content = attachment.get("content", "")
409
+ blocks.append(TextBlock(text=f'<attached-file path="{path}">\n{content}\n</attached-file>'))
410
+ return blocks
411
+
412
+ # ------------------------------------------------------------------
413
+ # Context budget
414
+ # ------------------------------------------------------------------
415
+
416
+ async def count_current_context(self) -> TokenCount:
417
+ self._sanitize_oversized_tool_results()
418
+ # Fix history before counting to get accurate count for what LLM will see
419
+ effective = self.get_effective_history_for_llm()
420
+ fixed_history = MessageHistory(self.fix_incomplete_tool_calls(list(effective)))
421
+ token_count = await self.llm.count_tokens(
422
+ system=self.system_prompt,
423
+ messages=fixed_history,
424
+ model=self.config.long_context_config.model,
425
+ tools=self.tool_collection.get_tool_list(),
426
+ )
427
+
428
+ # Send context update event
429
+ await self._send_context_update(token_count)
430
+
431
+ return token_count
432
+
433
+ async def _send_context_update(self, token_count: TokenCount) -> None:
434
+ """Send an event to update the UI about current context usage."""
435
+ usage_percentage = (token_count.input_tokens / self.model_context_length) * 100
436
+
437
+ # Determine alert level based on usage
438
+ alert_level = "normal"
439
+ message = None
440
+
441
+ if usage_percentage >= 60:
442
+ alert_level = "info"
443
+ message = (
444
+ "Longer threads consume more credits. "
445
+ f"Contents will be compressed automatically at {self.history_compression_threshold * 100:.0f}%. "
446
+ "You can start fresh by clicking \"New Thread\" in the sidebar."
447
+ )
448
+
449
+ await self.emitter.context_update(
450
+ input_tokens=token_count.input_tokens,
451
+ model_context_length=self.model_context_length,
452
+ compression_threshold=self.history_compression_threshold,
453
+ alert_level=alert_level,
454
+ message=message,
455
+ )
456
+
457
+ async def compress_history(self) -> None:
458
+ """
459
+ Non-destructively summarize the current history and mark a compression boundary.
460
+ """
461
+
462
+ async def on_info(message: str) -> None:
463
+ await self.log_info(message, sender=self.agent_name)
464
+
465
+ async def on_error(message: str) -> None:
466
+ await self.log_error(message, sender=self.agent_name)
467
+
468
+ await self.compressor.summarize(
469
+ self.conversation,
470
+ llm=self.llm,
471
+ model=self.config.long_context_config.model,
472
+ max_completion_tokens=self.model_completion_tokens,
473
+ temperature=self.model_default_temperature,
474
+ thinking=self.config.long_context_config.thinking_tokens,
475
+ on_info=on_info,
476
+ on_error=on_error,
477
+ )
478
+
479
+ # ------------------------------------------------------------------
480
+ # Tool execution
481
+ # ------------------------------------------------------------------
482
+
483
+ @property
484
+ def current_tool_call_id(self):
485
+ """Internal execution ID for UI and sub-agent records (task-local)."""
486
+ return self._current_tool_call_id_var.get()
487
+
488
+ @current_tool_call_id.setter
489
+ def current_tool_call_id(self, value):
490
+ self._current_tool_call_id_var.set(value)
491
+
492
+ @property
493
+ def current_tool_execution_id(self):
494
+ """App-unique tool execution ID for the tool call currently running (task-local)."""
495
+ return self._current_tool_execution_id_var.get()
496
+
497
+ @current_tool_execution_id.setter
498
+ def current_tool_execution_id(self, value):
499
+ self._current_tool_execution_id_var.set(value)
500
+
501
+ @property
502
+ def current_provider_tool_call_id(self):
503
+ """Provider-issued tool call ID for the tool call currently running (task-local)."""
504
+ return self._current_provider_tool_call_id_var.get()
505
+
506
+ @current_provider_tool_call_id.setter
507
+ def current_provider_tool_call_id(self, value):
508
+ self._current_provider_tool_call_id_var.set(value)
509
+
510
+ async def execute_single_tool(self, tool_use_block: ToolCall) -> ToolResult:
511
+ """Execute a single tool and return its result with metadata"""
512
+ tool_name = tool_use_block.name
513
+ inputs = tool_use_block.input
514
+ provider_tool_call_id = tool_use_block.id
515
+ tool_execution_id = getattr(tool_use_block, "execution_id", provider_tool_call_id)
516
+
517
+ # Keep provider IDs for LLM history while exposing an internal unique ID to app services.
518
+ self.current_provider_tool_call_id = provider_tool_call_id
519
+ self.current_tool_execution_id = tool_execution_id
520
+ self.current_tool_call_id = tool_execution_id
521
+
522
+ try:
523
+ registry = self.tool_collection.registry()
524
+ if tool_name not in registry:
525
+ error_message = f"Tool '{tool_name}' is not available in this mode."
526
+ await self.log_error(error_message, sender=self.agent_name)
527
+ await self.send_chat_message(
528
+ message_type="tool_error",
529
+ content=error_message,
530
+ is_streaming=False,
531
+ tool_description=tool_name,
532
+ tool_call_id=tool_execution_id,
533
+ )
534
+ return ToolResult(
535
+ tool_use_id=provider_tool_call_id,
536
+ content=error_message,
537
+ name=tool_name,
538
+ is_error=True,
539
+ execution_id=tool_execution_id,
540
+ )
541
+
542
+ # Log the tool being called
543
+ await self.log_info(f"Executing tool: {tool_name}", sender=self.agent_name)
544
+
545
+ # Send tool_call message to indicate we're starting execution
546
+ if not all(
547
+ [
548
+ self.config.long_context_config.provider == ModelProvider.ANTHROPIC,
549
+ tool_name in self.long_content_tool_calls,
550
+ ]
551
+ ):
552
+ await self.send_chat_message(
553
+ message_type="tool_call",
554
+ content=f"Calling {tool_name}",
555
+ is_streaming=False,
556
+ tool_description=tool_name,
557
+ tool_call_id=tool_execution_id,
558
+ )
559
+
560
+ output = await registry.call(tool_name, **inputs)
561
+
562
+ # Handle the case where the output is a list of ContentBlock objects
563
+ chat_message_content = output
564
+ if isinstance(output, list):
565
+ chat_message_content = "\n\n".join(item.to_markdown() for item in output)
566
+
567
+ if tool_name == "write_memory":
568
+ self._initialize_system_prompt()
569
+
570
+ # Send tool_result message for successful execution
571
+ await self.send_chat_message(
572
+ message_type="tool_result",
573
+ content=chat_message_content,
574
+ is_streaming=False,
575
+ tool_description=tool_name,
576
+ tool_call_id=tool_execution_id,
577
+ )
578
+
579
+ return ToolResult(
580
+ tool_use_id=provider_tool_call_id,
581
+ content=output,
582
+ name=tool_name,
583
+ is_error=False,
584
+ execution_id=tool_execution_id,
585
+ )
586
+ except ToolError as ex:
587
+ # Expected tool failure: surface to the model without an
588
+ # internal-error log.
589
+ error_message = str(ex)
590
+ await self.log_warning(f"Tool {tool_name} failed: {error_message}", sender=self.agent_name)
591
+
592
+ await self.send_chat_message(
593
+ message_type="tool_error",
594
+ content=error_message,
595
+ is_streaming=False,
596
+ tool_description=tool_name,
597
+ tool_call_id=tool_execution_id,
598
+ )
599
+
600
+ return ToolResult(
601
+ tool_use_id=provider_tool_call_id,
602
+ content=error_message,
603
+ name=tool_use_block.name,
604
+ is_error=True,
605
+ execution_id=tool_execution_id,
606
+ )
607
+ except Exception as ex:
608
+ error_message = str(ex)
609
+ await self.log_error(f"Error executing tool {tool_name}: {error_message}", sender=self.agent_name)
610
+
611
+ # Send tool_error message for failed execution
612
+ await self.send_chat_message(
613
+ message_type="tool_error",
614
+ content=error_message,
615
+ is_streaming=False,
616
+ tool_description=tool_name,
617
+ tool_call_id=tool_execution_id,
618
+ )
619
+
620
+ return ToolResult(
621
+ tool_use_id=provider_tool_call_id,
622
+ content=error_message,
623
+ name=tool_use_block.name,
624
+ is_error=True,
625
+ execution_id=tool_execution_id,
626
+ )
627
+ finally:
628
+ # Clear current tool call ID after execution
629
+ self.current_tool_call_id = None
630
+ self.current_tool_execution_id = None
631
+ self.current_provider_tool_call_id = None
632
+
633
+ async def process_tool_calls(self, tool_use_blocks: List[ToolCall]) -> List[ToolResult]:
634
+ """
635
+ Process multiple tool calls either in parallel or sequentially based on tool types.
636
+
637
+ Args:
638
+ tool_use_blocks: List of tool use blocks from the LLM
639
+
640
+ Returns:
641
+ List of tool responses with metadata
642
+ """
643
+ # If only one tool call, just execute it directly
644
+ if len(tool_use_blocks) == 1:
645
+ return [await self.execute_single_tool(tool_use_blocks[0])]
646
+
647
+ # A batch runs concurrently only when every tool in it is marked
648
+ # parallel-safe (read-only operations and independent sub-agent
649
+ # dispatches); any other tool forces sequential execution.
650
+ registry = self.tool_collection.registry()
651
+ all_parallel_safe = all(
652
+ block.name in registry and registry.get(block.name).parallel_safe for block in tool_use_blocks
653
+ )
654
+
655
+ if all_parallel_safe:
656
+ # Execute all tools in parallel
657
+ await self.log_info(
658
+ f"Executing {len(tool_use_blocks)} parallel-safe tool calls in parallel", sender=self.agent_name
659
+ )
660
+ semaphore = asyncio.Semaphore(self.PARALLEL_TOOL_LIMIT)
661
+
662
+ async def run_limited(block: ToolCall) -> ToolResult:
663
+ async with semaphore:
664
+ return await self.execute_single_tool(block)
665
+
666
+ # Wait for all tasks to complete; gather preserves input order so
667
+ # tool results stay aligned with their tool calls in history.
668
+ results = await asyncio.gather(*(run_limited(block) for block in tool_use_blocks))
669
+ return list(results)
670
+ else:
671
+ # Execute tools sequentially
672
+ await self.log_info(
673
+ f"Executing {len(tool_use_blocks)} tool calls sequentially (some are not read-only)",
674
+ sender=self.agent_name,
675
+ )
676
+ results = []
677
+ for block in tool_use_blocks:
678
+ result = await self.execute_single_tool(block)
679
+ results.append(result)
680
+ return results
681
+
682
+ # ------------------------------------------------------------------
683
+ # Events
684
+ # ------------------------------------------------------------------
685
+
686
+ def _sub_agent_info(self) -> Optional[Dict[str, Any]]:
687
+ """Sub-agent dispatch metadata included in chat events, when applicable."""
688
+ if self.sub_agent and self.sub_agent_context:
689
+ # Dispatch metadata set by AgentTool (agent_id, task, parent IDs)
690
+ return dict(self.sub_agent_context)
691
+ if self.sub_agent and self.parent_tool_call_id:
692
+ return {
693
+ "agent_name": self.agent_name,
694
+ "conversation_id": self.conversation_id,
695
+ "parent_tool_call_id": self.parent_tool_call_id,
696
+ "depth": 1, # Can be enhanced to track nested depth
697
+ }
698
+ return None
699
+
700
+ async def send_chat_message(
701
+ self, message_type: str, content: str, is_streaming: bool = False, tool_description=None, tool_call_id=None
702
+ ) -> None:
703
+ """
704
+ Send a message to the chat interface.
705
+
706
+ Args:
707
+ content: The message content to send
708
+ is_streaming: Whether this is part of a streaming message
709
+ """
710
+ await self.emitter.chat(
711
+ message_type,
712
+ content,
713
+ is_streaming=is_streaming,
714
+ tool_description=tool_description,
715
+ tool_call_id=tool_call_id,
716
+ )
717
+
718
+ async def handle_llm_error(self, error: Exception) -> None:
719
+ """
720
+ Handle LLM errors with appropriate retry logic and logging.
721
+
722
+ This method provides centralized error handling for all LLM operations:
723
+ - Rate limit errors: Log warning, wait 60 seconds, and allow retry
724
+ - Other LLM errors: Log error and re-raise
725
+ - Non-LLM errors: Re-raise as-is
726
+
727
+ Args:
728
+ error: The exception to handle
729
+
730
+ Raises:
731
+ LLMError: Re-raises LLM errors after logging
732
+ Exception: Re-raises non-LLM exceptions as-is
733
+ """
734
+ error = map_to_llm_error(error, provider=self.config.long_context_config.provider.value)
735
+
736
+ if isinstance(error, LLMRateLimitError):
737
+ await self.log_warning(
738
+ f"Rate limit exceeded: {error}. Waiting for 60 seconds before retrying...", sender=self.agent_name
739
+ )
740
+ await asyncio.sleep(60)
741
+ await self.log_info("Resuming after rate limit wait period.", sender=self.agent_name)
742
+ # Don't re-raise - allow retry
743
+
744
+ elif isinstance(error, LLMInternalServerError):
745
+ await self.emitter.llm_status(
746
+ "error",
747
+ "There is high traffic on our LLM provider right now. Please try again in a few seconds.",
748
+ )
749
+ raise
750
+
751
+ elif isinstance(error, LLMContextWindowExceededError):
752
+ await self.emitter.llm_status(
753
+ "error",
754
+ (
755
+ "The conversation context became too large for the model. "
756
+ "Oversized tool output is trimmed automatically; please retry the message."
757
+ ),
758
+ )
759
+ raise
760
+
761
+ elif isinstance(error, LLMError):
762
+ await self.log_error(f"LLM error occurred: {error}", sender=self.agent_name)
763
+ raise # Re-raise to maintain current behavior
764
+ else:
765
+ # Non-LLM error - just re-raise
766
+ raise
767
+
768
+ # ------------------------------------------------------------------
769
+ # The agent loop
770
+ #
771
+ # process_message_stream is the single canonical loop shared by every
772
+ # agent. Subclasses customize behavior through the hook methods below
773
+ # (build_user_content, apply_compression_fallback, on_tool_use_start,
774
+ # should_stop_after_tools, recap_agent_outcome) rather than overriding
775
+ # the loop itself.
776
+ # ------------------------------------------------------------------
777
+
778
+ completion_log_message = "Processing complete"
779
+
780
+ async def build_user_content(self, message: str, attachments: Optional[List[Dict[str, Any]]]) -> List[Any]:
781
+ """
782
+ Build the content blocks for an incoming user message.
783
+
784
+ Default: the message text plus blocks for any image/file attachments.
785
+ """
786
+ content_blocks: List[Any] = [TextBlock(text=message)]
787
+ content_blocks.extend(self._attachment_blocks(attachments))
788
+
789
+ for attachment in attachments or []:
790
+ if attachment.get("type") == "image":
791
+ await self.log_info(
792
+ f"Received image attachment: {attachment.get('filename', 'unnamed')} ({attachment.get('media_type', 'unknown')})",
793
+ sender=self.agent_name,
794
+ )
795
+ elif attachment.get("type") == "file":
796
+ await self.log_info(
797
+ f"Attached file from @ mention: {attachment.get('path', 'unnamed')}",
798
+ sender=self.agent_name,
799
+ )
800
+
801
+ return content_blocks
802
+
803
+ def apply_compression_fallback(self) -> None:
804
+ """
805
+ Hard-truncate history when compression alone could not get under budget.
806
+
807
+ Default: keep the first message (the original task) plus any protected
808
+ skill-content messages.
809
+ """
810
+ first_message = self.history[0]
811
+ protected = [
812
+ message
813
+ for message in self.history
814
+ if message is not first_message and self._is_protected_skill_content(message)
815
+ ]
816
+ self.history = MessageHistory(protected + [first_message])
817
+
818
+ async def on_tool_use_start(self, tool_call_delta: Dict[str, Any]) -> None:
819
+ """
820
+ Called when the provider streams a tool_use_start event (Anthropic only).
821
+
822
+ Long-content tools stream large arguments, so announce them as soon as
823
+ they start instead of waiting for the arguments to finish streaming
824
+ (execute_single_tool skips the announcement for these tools).
825
+ """
826
+ tool_name = tool_call_delta.get("name")
827
+ tool_execution_id = tool_call_delta.get("execution_id") or tool_call_delta.get("id")
828
+ if tool_name in self.long_content_tool_calls:
829
+ await self.send_chat_message(
830
+ message_type="tool_call",
831
+ content=f"Calling {tool_name}",
832
+ is_streaming=False,
833
+ tool_description=tool_name,
834
+ tool_call_id=tool_execution_id,
835
+ )
836
+
837
+ def should_stop_after_tools(self) -> bool:
838
+ """Return True to end the loop after a successful tool batch (e.g. a plan was written)."""
839
+ return False
840
+
841
+ async def recap_agent_outcome(self) -> str:
842
+ """Return the agent's final report: the text of the last message in history."""
843
+ return self.history[-1].get_text_content()
844
+
845
+ async def process_message_stream(
846
+ self, message: str, attachments: List[Dict[str, Any]] = None
847
+ ) -> AsyncGenerator[Dict[str, Any], None]:
848
+ """
849
+ Process a user message and yield response/thinking chunks while the agent works.
850
+
851
+ Yields dicts of the form
852
+ ``{"type": "response"|"thinking", "content": str, "complete": bool, "uuid": str}``.
853
+ """
854
+ unsupported_attachment_message = self._unsupported_attachment_message(attachments)
855
+ if unsupported_attachment_message:
856
+ yield {
857
+ "type": "response",
858
+ "content": unsupported_attachment_message,
859
+ "complete": True,
860
+ "uuid": str(uuid.uuid4()),
861
+ }
862
+ return
863
+
864
+ self.append_user_message(await self.build_user_content(message, attachments))
865
+
866
+ stop_reason = None
867
+ while stop_reason not in ["end_turn", "max_tokens", "stop_sequence"]:
868
+ self.mark_cache_checkpoint()
869
+
870
+ try:
871
+ token_count = await self.count_current_context()
872
+ logger.debug("Input token count: %s", token_count)
873
+
874
+ if self.compressor.over_budget(token_count.input_tokens, self.model_context_length):
875
+ await self.compress_history()
876
+ token_count = await self.count_current_context()
877
+
878
+ if self.compressor.over_budget(token_count.input_tokens, self.model_context_length):
879
+ self.apply_compression_fallback()
880
+
881
+ self.mark_cache_checkpoint()
882
+
883
+ current_response = ""
884
+ current_thinking = ""
885
+ thinking_started = False
886
+ # Use the same UUID for each segment of the response
887
+ response_uuid = str(uuid.uuid4())
888
+ thinking_uuid = str(uuid.uuid4())
889
+
890
+ # Fix history before sending to LLM to ensure valid tool call sequences
891
+ effective = self.get_effective_history_for_llm()
892
+ fixed_history = MessageHistory(self.fix_incomplete_tool_calls(list(effective)))
893
+
894
+ async with await self.llm.stream(
895
+ system=self.system_prompt,
896
+ max_completion_tokens=self.model_completion_tokens,
897
+ temperature=self.model_default_temperature,
898
+ messages=fixed_history,
899
+ model=self.config.long_context_config.model,
900
+ tools=self.tool_collection.get_tool_list(),
901
+ thinking=self.config.long_context_config.thinking_tokens,
902
+ ) as stream:
903
+ async for event in stream:
904
+ if event.type == "text":
905
+ current_response += event.text
906
+
907
+ # Send periodic updates as the response grows
908
+ if len(current_response) >= 50:
909
+ yield {
910
+ "type": "response",
911
+ "content": current_response,
912
+ "complete": False,
913
+ "uuid": response_uuid,
914
+ }
915
+ current_response = ""
916
+
917
+ elif event.type == "thinking" and event.thinking:
918
+ current_thinking += event.thinking
919
+
920
+ if len(current_thinking) >= 50:
921
+ thinking_started = True
922
+ yield {
923
+ "type": "thinking",
924
+ "content": current_thinking,
925
+ "complete": False,
926
+ "uuid": thinking_uuid,
927
+ }
928
+ current_thinking = ""
929
+
930
+ elif event.type == "tool_use_start" and event.tool_call_delta:
931
+ # Flush accumulated text first so the user doesn't have to wait for it.
932
+ yield {
933
+ "type": "response",
934
+ "content": current_response,
935
+ "complete": True,
936
+ "uuid": response_uuid,
937
+ }
938
+ current_response = ""
939
+
940
+ await self.on_tool_use_start(event.tool_call_delta)
941
+
942
+ assistant_message = await stream.get_final_message()
943
+ stop_reason = assistant_message.stop_reason
944
+
945
+ self.append_assistant_message(assistant_message)
946
+
947
+ if thinking_started or current_thinking:
948
+ yield {"type": "thinking", "content": current_thinking, "complete": True, "uuid": thinking_uuid}
949
+
950
+ # Send the final message to mark it complete.
951
+ yield {"type": "response", "content": current_response, "complete": True, "uuid": response_uuid}
952
+
953
+ if assistant_message.tool_calls:
954
+ await self.log_info(
955
+ f"Received {len(assistant_message.tool_calls)} tool call(s)", sender=self.agent_name
956
+ )
957
+
958
+ try:
959
+ tool_responses = await self.process_tool_calls(assistant_message.tool_calls)
960
+ self.append_user_message(tool_responses)
961
+
962
+ if self.should_stop_after_tools():
963
+ break
964
+ except Exception as ex:
965
+ error_message = f"Error processing tool calls: {str(ex)}"
966
+ await self.log_error(error_message, sender=self.agent_name)
967
+
968
+ error_responses = [
969
+ ToolResult(
970
+ tool_use_id=tool_call.id,
971
+ content=f"Failed to process tool calls: {str(ex)}",
972
+ name=tool_call.name,
973
+ is_error=True,
974
+ )
975
+ for tool_call in assistant_message.tool_calls
976
+ ]
977
+ self.append_user_message(error_responses)
978
+
979
+ except Exception as ex:
980
+ await self.handle_llm_error(ex)
981
+
982
+ await self.log_info(self.completion_log_message, sender=self.agent_name)
983
+
984
+ # ------------------------------------------------------------------
985
+ # Lifecycle
986
+ # ------------------------------------------------------------------
987
+
988
+ async def cleanup(self) -> None:
989
+ """
990
+ Clean up all agent resources.
991
+ This should be called when the agent is being destroyed.
992
+ """
993
+ # Clean up tool collection resources
994
+ if hasattr(self, "tool_collection"):
995
+ await self.tool_collection.cleanup()
996
+
997
+ # Log cleanup
998
+ await self.log_info("Agent cleanup completed", sender=self.agent_name)