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