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,408 @@
1
+ """Conversation: owns the message history and its invariants.
2
+
3
+ Centralizes the rules that keep a conversation valid for LLM providers:
4
+ tool-call/tool-result pairing, cache checkpoints, the compression boundary,
5
+ and oversized-tool-result sanitization. BaseAgent delegates here; host
6
+ subclasses should reach this through the BaseAgent methods rather than
7
+ holding their own reference.
8
+ """
9
+
10
+ import logging
11
+ import re
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ from kolega_code.llm.models import Message, MessageHistory, TextBlock, ToolCall, ToolResult
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class Conversation:
20
+ """Message history plus the invariants that keep it valid for providers."""
21
+
22
+ skill_content_pattern = re.compile(r'<skill_content name="[^"]+">')
23
+
24
+ def __init__(
25
+ self,
26
+ messages: Optional[List[Message]] = None,
27
+ *,
28
+ max_tool_result_chars: int = 100_000,
29
+ ) -> None:
30
+ self.history = MessageHistory(list(messages) if messages else [])
31
+ # Compression marker: index of the last message before a summary was appended
32
+ self.last_compression_index: Optional[int] = None
33
+ self.max_tool_result_chars = max_tool_result_chars
34
+
35
+ # ------------------------------------------------------------------
36
+ # Appending
37
+ # ------------------------------------------------------------------
38
+
39
+ def append_user(self, content) -> None:
40
+ """
41
+ Safely append a user message, reconciling incoming tool results with
42
+ any placeholder or duplicate results already in history.
43
+
44
+ Args:
45
+ content: Either a string (converted to TextBlock) or list of ContentBlocks
46
+ """
47
+ if isinstance(content, str):
48
+ content_blocks = [TextBlock(text=content)]
49
+ elif isinstance(content, list):
50
+ content_blocks = content
51
+ else:
52
+ content_blocks = [content]
53
+
54
+ if isinstance(content_blocks, list):
55
+ new_tool_results = {}
56
+ other_blocks = []
57
+
58
+ for block in content_blocks:
59
+ if isinstance(block, ToolResult):
60
+ new_tool_results[block.tool_use_id] = block
61
+ else:
62
+ other_blocks.append(block)
63
+
64
+ if new_tool_results:
65
+ # Find and update any existing tool results with the same IDs
66
+ for i, msg in enumerate(self.history):
67
+ if msg.role == "user" and isinstance(msg.content, list):
68
+ updated_content = []
69
+ replaced_any = False
70
+
71
+ for block in msg.content:
72
+ if isinstance(block, ToolResult) and block.tool_use_id in new_tool_results:
73
+ new_result = new_tool_results[block.tool_use_id]
74
+ # Replace if: old is dummy error OR new is success and old is error
75
+ should_replace = (block.is_error and "Operation was interrupted" in block.content) or (
76
+ not new_result.is_error and block.is_error
77
+ )
78
+
79
+ if should_replace:
80
+ updated_content.append(new_result)
81
+ replaced_any = True
82
+ logger.debug("Replaced tool result for tool_use_id: %s", block.tool_use_id)
83
+ del new_tool_results[block.tool_use_id]
84
+ else:
85
+ updated_content.append(block)
86
+ if block.tool_use_id in new_tool_results:
87
+ logger.debug(
88
+ "Skipping duplicate tool result for tool_use_id: %s", block.tool_use_id
89
+ )
90
+ del new_tool_results[block.tool_use_id]
91
+ else:
92
+ updated_content.append(block)
93
+
94
+ if replaced_any:
95
+ self.history[i] = Message(
96
+ role=msg.role, content=updated_content, stop_reason=msg.stop_reason
97
+ )
98
+
99
+ # Add any remaining new tool results along with other blocks
100
+ content_blocks = list(new_tool_results.values()) + other_blocks
101
+
102
+ # If all blocks were handled (replaced or skipped), don't add empty message
103
+ if not content_blocks:
104
+ return
105
+
106
+ if not content_blocks or (isinstance(content_blocks, list) and len(content_blocks) == 0):
107
+ logger.warning("User message has empty content, replacing with placeholder")
108
+ content_blocks = [TextBlock(text="[User provided no message content]")]
109
+
110
+ self.history.append(Message(role="user", content=content_blocks))
111
+
112
+ def append_assistant(self, message: Message) -> None:
113
+ """Safely append an assistant message, replacing empty content with a placeholder."""
114
+ if not message.content or (isinstance(message.content, list) and len(message.content) == 0):
115
+ logger.warning("Assistant message has empty content, replacing with placeholder")
116
+ message = Message(
117
+ role=message.role,
118
+ content=[TextBlock(text="[Assistant returned no message content]")],
119
+ stop_reason=message.stop_reason,
120
+ tool_calls=message.tool_calls,
121
+ )
122
+
123
+ self.history.append(message)
124
+
125
+ def extend(self, messages: List[Message]) -> None:
126
+ """Extend history with multiple messages, repairing incomplete tool calls in the result."""
127
+ all_messages = list(self.history) + messages
128
+ self.history = MessageHistory(self.repaired(all_messages))
129
+
130
+ # ------------------------------------------------------------------
131
+ # Views and validity
132
+ # ------------------------------------------------------------------
133
+
134
+ def effective_history(self) -> MessageHistory:
135
+ """
136
+ Return the subset of history to send to the LLM:
137
+ - If compressed: protected skill content + summary + all messages after the summary
138
+ - Else: the full history
139
+ """
140
+ if self.last_compression_index is not None and self.history:
141
+ # Summary is the message immediately after the boundary
142
+ summary_idx = self.last_compression_index + 1
143
+ if summary_idx < len(self.history):
144
+ summary_msg = self.history[summary_idx]
145
+ protected = [message for message in self.history[:summary_idx] if self.is_protected(message)]
146
+ # Tail starts after the summary
147
+ tail = list(self.history[summary_idx + 1 :]) if summary_idx + 1 < len(self.history) else []
148
+ return MessageHistory(protected + [summary_msg] + tail)
149
+
150
+ return MessageHistory(list(self.history))
151
+
152
+ def is_protected(self, message: Message) -> bool:
153
+ """True for user messages carrying skill content that must survive compression."""
154
+ if message.role != "user":
155
+ return False
156
+ if isinstance(message.content, str):
157
+ return bool(self.skill_content_pattern.search(message.content))
158
+ if not isinstance(message.content, list):
159
+ return False
160
+ return any(
161
+ isinstance(block, TextBlock) and self.skill_content_pattern.search(block.text)
162
+ for block in message.content
163
+ )
164
+
165
+ def is_valid_for_anthropic(self, messages: Optional[List[Message]] = None) -> bool:
166
+ """
167
+ Check that every tool_use block is followed by a matching tool_result block,
168
+ as the Anthropic API requires.
169
+ """
170
+ if messages is None:
171
+ messages = list(self.history)
172
+
173
+ for i, message in enumerate(messages):
174
+ if message.role == "assistant" and isinstance(message.content, list):
175
+ tool_calls = [block for block in message.content if isinstance(block, ToolCall)]
176
+
177
+ if tool_calls:
178
+ if i + 1 >= len(messages):
179
+ return False # No next message
180
+
181
+ next_message = messages[i + 1]
182
+ if next_message.role != "user":
183
+ return False # Next message should be user role
184
+
185
+ if not isinstance(next_message.content, list):
186
+ return False # Should contain list of tool results
187
+
188
+ tool_call_ids = {call.id for call in tool_calls}
189
+ tool_result_ids = {
190
+ block.tool_use_id for block in next_message.content if isinstance(block, ToolResult)
191
+ }
192
+
193
+ if not tool_call_ids.issubset(tool_result_ids):
194
+ return False # Missing tool results
195
+
196
+ return True
197
+
198
+ def needs_tool_call_fix(self) -> bool:
199
+ """True if the last message is an assistant message with pending tool calls."""
200
+ if not self.history:
201
+ return False
202
+
203
+ last_message = self.history[-1]
204
+
205
+ if last_message.role != "assistant":
206
+ return False
207
+
208
+ if not isinstance(last_message.content, list):
209
+ return False
210
+
211
+ return any(isinstance(block, ToolCall) for block in last_message.content)
212
+
213
+ def repaired(self, messages: Optional[List[Message]] = None) -> List[Message]:
214
+ """
215
+ Repair incomplete tool call sequences by reuniting displaced tool results
216
+ with their tool calls and adding placeholder results for orphaned calls.
217
+
218
+ Args:
219
+ messages: Messages to repair; defaults to the current history.
220
+
221
+ Returns:
222
+ List[Message]: Repaired messages safe to send to a provider.
223
+ """
224
+ if messages is None:
225
+ messages = list(self.history)
226
+ if not messages:
227
+ return messages
228
+
229
+ fixed_messages = []
230
+ i = 0
231
+ processed_indices = set() # Track which messages we've already processed
232
+
233
+ while i < len(messages):
234
+ if i in processed_indices:
235
+ i += 1
236
+ continue
237
+
238
+ current_message = messages[i]
239
+
240
+ if current_message.role == "assistant" and isinstance(current_message.content, list):
241
+ tool_calls = [block for block in current_message.content if isinstance(block, ToolCall)]
242
+
243
+ if tool_calls:
244
+ fixed_messages.append(current_message)
245
+ processed_indices.add(i)
246
+
247
+ # Collect all tool results from the entire remaining conversation
248
+ tool_call_ids = {call.id for call in tool_calls}
249
+ all_tool_results = {}
250
+ other_content_blocks = [] # Non-tool-result content from the next user message
251
+
252
+ # First, check the immediately following message (expected position)
253
+ next_user_message = None
254
+ if i + 1 < len(messages) and messages[i + 1].role == "user":
255
+ next_user_message = messages[i + 1]
256
+ if isinstance(next_user_message.content, list):
257
+ for block in next_user_message.content:
258
+ if isinstance(block, ToolResult) and block.tool_use_id in tool_call_ids:
259
+ all_tool_results[block.tool_use_id] = block
260
+ else:
261
+ other_content_blocks.append(block)
262
+
263
+ if all_tool_results:
264
+ processed_indices.add(i + 1)
265
+
266
+ # Search the entire remaining conversation for any missing tool results
267
+ missing_ids = tool_call_ids - set(all_tool_results.keys())
268
+ if missing_ids:
269
+ for j in range(i + 1, len(messages)):
270
+ if j in processed_indices:
271
+ continue
272
+
273
+ msg = messages[j]
274
+ if msg.role == "user" and isinstance(msg.content, list):
275
+ remaining_content = []
276
+ found_any = False
277
+
278
+ for block in msg.content:
279
+ if isinstance(block, ToolResult) and block.tool_use_id in missing_ids:
280
+ logger.warning(
281
+ "Found tool result %s at position %s instead of expected position %s",
282
+ block.tool_use_id,
283
+ j,
284
+ i + 1,
285
+ )
286
+ all_tool_results[block.tool_use_id] = block
287
+ missing_ids.remove(block.tool_use_id)
288
+ found_any = True
289
+ else:
290
+ remaining_content.append(block)
291
+
292
+ if found_any:
293
+ if remaining_content:
294
+ # Message has other content - keep it but remove tool results
295
+ updated_msg = Message(
296
+ role=msg.role, content=remaining_content, stop_reason=msg.stop_reason
297
+ )
298
+ messages[j] = updated_msg
299
+ else:
300
+ # Message only had tool results - mark for skipping
301
+ processed_indices.add(j)
302
+
303
+ # Create the complete tool results list in the correct order
304
+ complete_tool_results = []
305
+ for tool_call in tool_calls:
306
+ if tool_call.id in all_tool_results:
307
+ complete_tool_results.append(all_tool_results[tool_call.id])
308
+ else:
309
+ logger.warning("Adding placeholder result for missing tool call: %s", tool_call.id)
310
+ complete_tool_results.append(
311
+ ToolResult(
312
+ tool_use_id=tool_call.id,
313
+ content="Operation was interrupted. Please retry if needed.",
314
+ name=tool_call.name,
315
+ is_error=True,
316
+ )
317
+ )
318
+
319
+ # Create the user message with all tool results, plus any other
320
+ # content that was in the original next user message
321
+ all_content = complete_tool_results + other_content_blocks
322
+ if all_content:
323
+ complete_user_message = Message(
324
+ role="user",
325
+ content=all_content,
326
+ stop_reason=next_user_message.stop_reason if next_user_message else None,
327
+ )
328
+ fixed_messages.append(complete_user_message)
329
+
330
+ i += 1
331
+ else:
332
+ fixed_messages.append(current_message)
333
+ processed_indices.add(i)
334
+ i += 1
335
+ else:
336
+ # Not an assistant message with tool calls
337
+ # Skip if already processed (was a tool result message we moved)
338
+ if i not in processed_indices:
339
+ fixed_messages.append(current_message)
340
+ i += 1
341
+
342
+ return fixed_messages
343
+
344
+ # ------------------------------------------------------------------
345
+ # Maintenance
346
+ # ------------------------------------------------------------------
347
+
348
+ def mark_cache_checkpoint(self) -> None:
349
+ """
350
+ Mark only the last content block of the last message for prompt caching,
351
+ clearing the marker everywhere else (Anthropic allows max 4 cache blocks).
352
+ """
353
+ for message in self.history:
354
+ if hasattr(message, "content") and isinstance(message.content, list):
355
+ for content_block in message.content:
356
+ if hasattr(content_block, "cache_checkpoint"):
357
+ content_block.cache_checkpoint = False
358
+
359
+ if self.history:
360
+ last_message = self.history[-1]
361
+ if hasattr(last_message, "content") and isinstance(last_message.content, list) and last_message.content:
362
+ last_content_block = last_message.content[-1]
363
+ if hasattr(last_content_block, "cache_checkpoint"):
364
+ last_content_block.cache_checkpoint = True
365
+
366
+ def sanitize_oversized_tool_results(self) -> int:
367
+ """Replace tool results above the size cap with an explanatory placeholder."""
368
+ sanitized_count = 0
369
+ for message in self.history:
370
+ if not isinstance(message.content, list):
371
+ continue
372
+
373
+ for block in message.content:
374
+ if not isinstance(block, ToolResult) or not isinstance(block.content, str):
375
+ continue
376
+
377
+ content_length = len(block.content)
378
+ if content_length <= self.max_tool_result_chars:
379
+ continue
380
+
381
+ block.content = (
382
+ f"[Tool result omitted from history because it was {content_length:,} characters, "
383
+ f"exceeding the {self.max_tool_result_chars:,} character safety cap. "
384
+ f"Re-run `{block.name}` with narrower inputs if the content is still needed.]"
385
+ )
386
+ sanitized_count += 1
387
+
388
+ return sanitized_count
389
+
390
+ def record_compression(self, summary: Message) -> None:
391
+ """Append a compression summary and mark the boundary before it."""
392
+ self.history.append(summary)
393
+ self.last_compression_index = len(self.history) - 2
394
+
395
+ # ------------------------------------------------------------------
396
+ # Serialization
397
+ # ------------------------------------------------------------------
398
+
399
+ def dump(self) -> List[Dict[str, Any]]:
400
+ """Serialize the message history into a list of dictionaries."""
401
+ return [message.to_dict() for message in self.history]
402
+
403
+ def restore(self, serialized_history: List[Dict[str, Any]]) -> None:
404
+ """Restore the message history from a list of dictionaries."""
405
+ parsed_messages = [Message.from_dict(item) for item in serialized_history]
406
+ # Keep history authentic - no fixing here
407
+ self.history = MessageHistory(parsed_messages)
408
+ self.sanitize_oversized_tool_results()
@@ -0,0 +1,146 @@
1
+ from pathlib import Path
2
+ from typing import Any, Dict, List, Optional
3
+
4
+ from .baseagent import BaseAgent
5
+ from kolega_code.config import AgentConfig
6
+ from kolega_code.events import AgentConnectionManager
7
+ from kolega_code.llm.models import Message, TextBlock
8
+ from .prompt_provider import AgentMode, AgentType, PromptExtension
9
+ from .tools import ToolCollection, ToolCollectionConfig
10
+
11
+
12
+ class GeneralAgent(BaseAgent):
13
+ """
14
+ A general-purpose sub-agent with the full workspace toolset.
15
+
16
+ Dispatched by a parent agent to complete one self-contained task autonomously
17
+ (multiple GeneralAgents may run concurrently on independent tasks). It cannot
18
+ spawn further sub-agents, and its final message is returned to the parent as
19
+ the task report.
20
+ """
21
+
22
+ agent_name = "general-agent"
23
+
24
+ def __init__(
25
+ self,
26
+ project_path: str | Path,
27
+ workspace_id: str,
28
+ thread_id: str,
29
+ connection_manager: AgentConnectionManager,
30
+ config: AgentConfig,
31
+ sub_agent: bool = True,
32
+ filesystem=None,
33
+ terminal_manager=None,
34
+ browser_manager=None,
35
+ langfuse_client=None,
36
+ user_id: Optional[str] = None,
37
+ user_email: Optional[str] = None,
38
+ project_template_slug: Optional[str] = None,
39
+ protected_files: Optional[List[str]] = None,
40
+ agent_mode: Optional["AgentMode"] = None,
41
+ workspace_env_var_descriptions: Optional[Dict[str, str]] = None,
42
+ workspace_memories: Optional[List[str]] = None,
43
+ prompt_extensions: Optional[List[PromptExtension]] = None,
44
+ tool_extensions: Optional[List[Any]] = None,
45
+ usage_recorder: Optional[Any] = None,
46
+ sub_agent_recorder: Optional[Any] = None,
47
+ ) -> None:
48
+ """
49
+ Initialize a new GeneralAgent instance.
50
+
51
+ Args:
52
+ project_path: File system path to the project root directory
53
+ workspace_id: Identifier for the workspace
54
+ thread_id: Identifier for the thread
55
+ connection_manager: Manager for handling agent connections
56
+ config: Agent configuration
57
+ sub_agent: Whether this agent is a sub-agent of another agent
58
+ filesystem: Optional filesystem implementation
59
+ terminal_manager: Optional terminal manager implementation
60
+ browser_manager: Optional browser manager implementation
61
+ langfuse_client: Optional Langfuse client for LLM observability
62
+ user_id: Optional ID of user who created this job
63
+ user_email: Optional email of user who created this job
64
+ project_template_slug: Optional slug of the project template being used
65
+ protected_files: Optional list of file basenames protected from edits in vibe mode
66
+ agent_mode: Optional agent mode inherited from the dispatching agent
67
+ workspace_env_var_descriptions: Optional mapping of workspace environment variable descriptions
68
+ workspace_memories: Optional list of workspace memories to inject into prompts
69
+ prompt_extensions: Host-provided prompt sections for app-specific context
70
+ tool_extensions: Host-provided tool providers for app-specific tools
71
+ usage_recorder: Optional callback for recording normalized LLM usage
72
+ sub_agent_recorder: Optional callback for persisting sub-agent conversation state
73
+ """
74
+ super().__init__(
75
+ project_path,
76
+ workspace_id,
77
+ thread_id,
78
+ connection_manager,
79
+ config,
80
+ sub_agent=sub_agent,
81
+ filesystem=filesystem,
82
+ terminal_manager=terminal_manager,
83
+ browser_manager=browser_manager,
84
+ langfuse_client=langfuse_client,
85
+ user_id=user_id,
86
+ user_email=user_email,
87
+ project_template_slug=project_template_slug,
88
+ protected_files=protected_files,
89
+ agent_mode=agent_mode,
90
+ workspace_env_var_descriptions=workspace_env_var_descriptions,
91
+ workspace_memories=workspace_memories,
92
+ prompt_extensions=prompt_extensions,
93
+ tool_extensions=tool_extensions,
94
+ usage_recorder=usage_recorder,
95
+ sub_agent_recorder=sub_agent_recorder,
96
+ )
97
+
98
+ # Full coder-style toolset, minus sub-agent dispatch (a dispatched agent
99
+ # may not fan out further) and build tools in CLI mode.
100
+ tool_exclusions = [
101
+ "read_memory",
102
+ "write_memory",
103
+ "execute_terminal_command",
104
+ "replace_lines",
105
+ "apply_patch",
106
+ "edit_file",
107
+ "get_tool_list",
108
+ "log_error",
109
+ "log_info",
110
+ "run_command", # Disabled: unreliable completion detection, use run_command_tracked instead
111
+ # Recursion guard: a general sub-agent may not spawn further sub-agents
112
+ *ToolCollection.agent_dispatch_tools,
113
+ ]
114
+ mode_value = self.agent_mode.value if isinstance(self.agent_mode, AgentMode) else self.agent_mode
115
+ if mode_value == AgentMode.CLI.value:
116
+ tool_exclusions.extend(["build_backend", "build_frontend"])
117
+
118
+ tool_config = ToolCollectionConfig(tool_exclusions=tool_exclusions)
119
+
120
+ self.tool_collection = ToolCollection(
121
+ self.project_path,
122
+ self.workspace_id,
123
+ self.thread_id,
124
+ self.connection_manager,
125
+ self.config,
126
+ caller=self,
127
+ tool_config=tool_config,
128
+ filesystem=self.filesystem,
129
+ terminal_manager=self.terminal_manager,
130
+ browser_manager=self.browser_manager,
131
+ langfuse_client=self.langfuse_client,
132
+ tool_extensions=self.tool_extensions,
133
+ )
134
+
135
+ self._initialize_system_prompt()
136
+
137
+ def _initialize_system_prompt(self):
138
+ """Initialize system prompt using PromptProvider."""
139
+ prompt_text = self.prompt_provider.get_system_prompt(
140
+ agent_type=AgentType.GENERAL,
141
+ mode=self.agent_mode,
142
+ template_slug=self.project_template_slug,
143
+ prompt_extensions=self.prompt_extensions,
144
+ context=self.build_prompt_context(),
145
+ )
146
+ self.system_prompt = Message(role="system", content=[TextBlock(text=prompt_text)])
@@ -0,0 +1,123 @@
1
+ from pathlib import Path
2
+ from typing import Any, Dict, List, Optional
3
+
4
+ from .baseagent import BaseAgent
5
+ from kolega_code.config import AgentConfig
6
+ from kolega_code.events import AgentConnectionManager
7
+ from kolega_code.llm.models import Message, TextBlock
8
+ from .prompt_provider import AgentType, PromptExtension
9
+ from .tools import ToolCollection
10
+
11
+
12
+ class InvestigationAgent(BaseAgent):
13
+ """
14
+ An AI coding agent that operates within a workspace to assist with programming tasks.
15
+
16
+ The agent has access to the project filesystem and can perform coding operations
17
+ like reading, analyzing, and modifying code files.
18
+ """
19
+
20
+ agent_name = "investigation-agent"
21
+
22
+ def __init__(
23
+ self,
24
+ project_path: str | Path,
25
+ workspace_id: str,
26
+ thread_id: str,
27
+ connection_manager: AgentConnectionManager,
28
+ config: AgentConfig,
29
+ sub_agent: bool = True,
30
+ filesystem=None,
31
+ terminal_manager=None,
32
+ browser_manager=None,
33
+ langfuse_client=None,
34
+ user_id: Optional[str] = None,
35
+ user_email: Optional[str] = None,
36
+ project_template_slug: Optional[str] = None,
37
+ protected_files: Optional[List[str]] = None,
38
+ agent_mode: Optional["AgentMode"] = None,
39
+ workspace_env_var_descriptions: Optional[Dict[str, str]] = None,
40
+ workspace_memories: Optional[List[str]] = None,
41
+ prompt_extensions: Optional[List[PromptExtension]] = None,
42
+ tool_extensions: Optional[List[Any]] = None,
43
+ usage_recorder: Optional[Any] = None,
44
+ sub_agent_recorder: Optional[Any] = None,
45
+ ) -> None:
46
+ """
47
+ Initialize a new InvestigationAgent instance.
48
+
49
+ Args:
50
+ project_path: File system path to the project root directory
51
+ workspace_id: Identifier for the workspace
52
+ thread_id: Identifier for the thread
53
+ connection_manager: Manager for handling agent connections
54
+ config: Agent configuration
55
+ sub_agent: Whether this agent is a sub-agent of another agent
56
+ filesystem: Optional filesystem implementation
57
+ terminal_manager: Optional terminal manager implementation
58
+ browser_manager: Optional browser manager implementation
59
+ langfuse_client: Optional Langfuse client for LLM observability
60
+ user_id: Optional ID of user who created this job
61
+ user_email: Optional email of user who created this job
62
+ project_template_slug: Optional slug of the project template being used
63
+ protected_files: Optional list of file basenames protected from edits in vibe mode
64
+ agent_mode: Optional agent mode (not used for InvestigationAgent)
65
+ workspace_env_var_descriptions: Optional mapping of workspace environment variable descriptions
66
+ workspace_memories: Optional list of workspace memories to inject into prompts
67
+ prompt_extensions: Host-provided prompt sections for app-specific context
68
+ tool_extensions: Host-provided tool providers for app-specific tools
69
+ usage_recorder: Optional callback for recording normalized LLM usage
70
+ sub_agent_recorder: Optional callback for persisting sub-agent conversation state
71
+ """
72
+ super().__init__(
73
+ project_path,
74
+ workspace_id,
75
+ thread_id,
76
+ connection_manager,
77
+ config,
78
+ sub_agent=sub_agent,
79
+ filesystem=filesystem,
80
+ terminal_manager=terminal_manager,
81
+ browser_manager=browser_manager,
82
+ langfuse_client=langfuse_client,
83
+ user_id=user_id,
84
+ user_email=user_email,
85
+ project_template_slug=project_template_slug,
86
+ protected_files=protected_files,
87
+ agent_mode=agent_mode,
88
+ workspace_env_var_descriptions=workspace_env_var_descriptions,
89
+ workspace_memories=workspace_memories,
90
+ prompt_extensions=prompt_extensions,
91
+ tool_extensions=tool_extensions,
92
+ usage_recorder=usage_recorder,
93
+ sub_agent_recorder=sub_agent_recorder,
94
+ )
95
+
96
+ self.tool_collection = ToolCollection(
97
+ self.project_path,
98
+ self.workspace_id,
99
+ self.thread_id,
100
+ self.connection_manager,
101
+ self.config,
102
+ caller=self,
103
+ read_only=True,
104
+ filesystem=self.filesystem,
105
+ terminal_manager=self.terminal_manager,
106
+ browser_manager=self.browser_manager,
107
+ langfuse_client=self.langfuse_client,
108
+ tool_extensions=self.tool_extensions,
109
+ )
110
+
111
+ self._initialize_system_prompt()
112
+
113
+ def _initialize_system_prompt(self):
114
+ """Initialize system prompt using PromptProvider."""
115
+ # Generate prompt using the shared prompt provider
116
+ prompt_text = self.prompt_provider.get_system_prompt(
117
+ agent_type=AgentType.INVESTIGATION,
118
+ mode=self.agent_mode,
119
+ template_slug=self.project_template_slug,
120
+ prompt_extensions=self.prompt_extensions,
121
+ context=self.build_prompt_context(),
122
+ )
123
+ self.system_prompt = Message(role="system", content=[TextBlock(text=prompt_text)])