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,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)])
|