kolega-code 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. kolega_code/__init__.py +151 -0
  2. kolega_code/agent/__init__.py +42 -0
  3. kolega_code/agent/baseagent.py +998 -0
  4. kolega_code/agent/browseragent.py +123 -0
  5. kolega_code/agent/coder.py +157 -0
  6. kolega_code/agent/common.py +41 -0
  7. kolega_code/agent/compression.py +81 -0
  8. kolega_code/agent/context.py +112 -0
  9. kolega_code/agent/conversation.py +408 -0
  10. kolega_code/agent/generalagent.py +146 -0
  11. kolega_code/agent/investigationagent.py +123 -0
  12. kolega_code/agent/planningagent.py +187 -0
  13. kolega_code/agent/prompt_provider.py +196 -0
  14. kolega_code/agent/prompt_templates/agents/browser.j2 +102 -0
  15. kolega_code/agent/prompt_templates/agents/coder_cli_mode.j2 +127 -0
  16. kolega_code/agent/prompt_templates/agents/general.j2 +68 -0
  17. kolega_code/agent/prompt_templates/agents/investigation.j2 +72 -0
  18. kolega_code/agent/prompt_templates/common/frontend_guidance.md +36 -0
  19. kolega_code/agent/prompt_templates/common/kolega_md_instructions.md +14 -0
  20. kolega_code/agent/prompt_templates/environment_variables/workspace_env_vars.md +11 -0
  21. kolega_code/agent/prompt_templates/template_guidance/expo-template.md +379 -0
  22. kolega_code/agent/prompt_templates/template_guidance/html-website-template.md +3 -0
  23. kolega_code/agent/prompt_templates/template_guidance/mern-stack-template.md +3 -0
  24. kolega_code/agent/prompt_templates/template_guidance/react-vite-shadcdn-template.md +182 -0
  25. kolega_code/agent/prompts.py +192 -0
  26. kolega_code/agent/tests/__init__.py +0 -0
  27. kolega_code/agent/tests/llm/__init__.py +0 -0
  28. kolega_code/agent/tests/llm/test_anthropic_token_counting.py +633 -0
  29. kolega_code/agent/tests/llm/test_billing_openai_cache.py +74 -0
  30. kolega_code/agent/tests/llm/test_client.py +773 -0
  31. kolega_code/agent/tests/llm/test_dashscope_mapping.py +32 -0
  32. kolega_code/agent/tests/llm/test_error_boundary.py +322 -0
  33. kolega_code/agent/tests/llm/test_exceptions.py +249 -0
  34. kolega_code/agent/tests/llm/test_instrumented_client.py +536 -0
  35. kolega_code/agent/tests/llm/test_instrumented_client_integration.py +547 -0
  36. kolega_code/agent/tests/llm/test_langfuse_normalization.py +39 -0
  37. kolega_code/agent/tests/llm/test_model_specs.py +17 -0
  38. kolega_code/agent/tests/llm/test_openai_cached_tokens.py +58 -0
  39. kolega_code/agent/tests/llm/test_openai_cached_tokens_stream.py +74 -0
  40. kolega_code/agent/tests/llm/test_openai_message_conversion.py +30 -0
  41. kolega_code/agent/tests/llm/test_openai_token_counting.py +687 -0
  42. kolega_code/agent/tests/llm/test_tool_execution_ids.py +193 -0
  43. kolega_code/agent/tests/services/__init__.py +1 -0
  44. kolega_code/agent/tests/services/test_browser.py +447 -0
  45. kolega_code/agent/tests/services/test_browser_parity.py +353 -0
  46. kolega_code/agent/tests/services/test_file_system.py +699 -0
  47. kolega_code/agent/tests/services/test_sandbox_terminal_input.py +98 -0
  48. kolega_code/agent/tests/services/test_terminal.py +154 -0
  49. kolega_code/agent/tests/services/test_terminal_command_tracking.py +385 -0
  50. kolega_code/agent/tests/services/test_terminal_state_serializer.py +262 -0
  51. kolega_code/agent/tests/test_agent_tools_inventory.py +267 -0
  52. kolega_code/agent/tests/test_base_agent.py +1942 -0
  53. kolega_code/agent/tests/test_coder_attachments.py +330 -0
  54. kolega_code/agent/tests/test_coder_prompt_extensions.py +61 -0
  55. kolega_code/agent/tests/test_commands.py +179 -0
  56. kolega_code/agent/tests/test_duplicate_tool_results.py +556 -0
  57. kolega_code/agent/tests/test_empty_message_handling.py +48 -0
  58. kolega_code/agent/tests/test_general_agent.py +242 -0
  59. kolega_code/agent/tests/test_html.py +320 -0
  60. kolega_code/agent/tests/test_parallel_tool_calls.py +291 -0
  61. kolega_code/agent/tests/test_planning_agent.py +227 -0
  62. kolega_code/agent/tests/test_prompt_provider.py +271 -0
  63. kolega_code/agent/tests/test_tool_registry.py +102 -0
  64. kolega_code/agent/tests/test_tools.py +549 -0
  65. kolega_code/agent/tests/tool_backend/__init__.py +0 -0
  66. kolega_code/agent/tests/tool_backend/test_agent_tool.py +356 -0
  67. kolega_code/agent/tests/tool_backend/test_base_tool.py +147 -0
  68. kolega_code/agent/tests/tool_backend/test_browser_tool.py +335 -0
  69. kolega_code/agent/tests/tool_backend/test_build_tool.py +93 -0
  70. kolega_code/agent/tests/tool_backend/test_create_file_tool.py +115 -0
  71. kolega_code/agent/tests/tool_backend/test_glob_tool.py +196 -0
  72. kolega_code/agent/tests/tool_backend/test_glob_tool_sandbox_parity.py +230 -0
  73. kolega_code/agent/tests/tool_backend/test_list_directory_tool.py +292 -0
  74. kolega_code/agent/tests/tool_backend/test_read_file_tool.py +173 -0
  75. kolega_code/agent/tests/tool_backend/test_replace_entire_file_tool.py +115 -0
  76. kolega_code/agent/tests/tool_backend/test_replace_lines_tool.py +141 -0
  77. kolega_code/agent/tests/tool_backend/test_search_and_replace_tool.py +174 -0
  78. kolega_code/agent/tests/tool_backend/test_search_codebase_tool.py +228 -0
  79. kolega_code/agent/tests/tool_backend/test_terminal_tool.py +482 -0
  80. kolega_code/agent/tests/tool_backend/test_think_hard_integration.py +189 -0
  81. kolega_code/agent/tests/tool_backend/test_think_hard_streaming.py +445 -0
  82. kolega_code/agent/tests/tool_backend/test_web_fetch_tool.py +194 -0
  83. kolega_code/agent/tool_backend/agent_tool.py +414 -0
  84. kolega_code/agent/tool_backend/apply_edit_tool.py +98 -0
  85. kolega_code/agent/tool_backend/apply_patch_tool.py +514 -0
  86. kolega_code/agent/tool_backend/base_tool.py +217 -0
  87. kolega_code/agent/tool_backend/browser_tool.py +271 -0
  88. kolega_code/agent/tool_backend/build_tool.py +93 -0
  89. kolega_code/agent/tool_backend/create_file_tool.py +52 -0
  90. kolega_code/agent/tool_backend/glob_tool.py +323 -0
  91. kolega_code/agent/tool_backend/list_directory_tool.py +300 -0
  92. kolega_code/agent/tool_backend/memory_tool.py +79 -0
  93. kolega_code/agent/tool_backend/read_file_tool.py +119 -0
  94. kolega_code/agent/tool_backend/replace_entire_file_tool.py +40 -0
  95. kolega_code/agent/tool_backend/replace_lines_tool.py +97 -0
  96. kolega_code/agent/tool_backend/search_and_replace_tool.py +146 -0
  97. kolega_code/agent/tool_backend/search_codebase_tool.py +377 -0
  98. kolega_code/agent/tool_backend/streaming_tool.py +47 -0
  99. kolega_code/agent/tool_backend/terminal_tool.py +643 -0
  100. kolega_code/agent/tool_backend/think_hard_tool.py +211 -0
  101. kolega_code/agent/tool_backend/web_fetch_tool.py +205 -0
  102. kolega_code/agent/tools.py +1704 -0
  103. kolega_code/agent/utils/commands.py +94 -0
  104. kolega_code/cli/__init__.py +1 -0
  105. kolega_code/cli/app.py +2756 -0
  106. kolega_code/cli/config.py +280 -0
  107. kolega_code/cli/connection.py +49 -0
  108. kolega_code/cli/file_index.py +147 -0
  109. kolega_code/cli/main.py +564 -0
  110. kolega_code/cli/mentions.py +155 -0
  111. kolega_code/cli/messages.py +89 -0
  112. kolega_code/cli/provider_registry.py +96 -0
  113. kolega_code/cli/session_store.py +207 -0
  114. kolega_code/cli/settings.py +87 -0
  115. kolega_code/cli/skills.py +409 -0
  116. kolega_code/cli/slash_commands.py +108 -0
  117. kolega_code/cli/tests/__init__.py +1 -0
  118. kolega_code/cli/tests/test_app.py +4251 -0
  119. kolega_code/cli/tests/test_cli_config.py +171 -0
  120. kolega_code/cli/tests/test_connection.py +26 -0
  121. kolega_code/cli/tests/test_file_index.py +103 -0
  122. kolega_code/cli/tests/test_main.py +455 -0
  123. kolega_code/cli/tests/test_mentions.py +108 -0
  124. kolega_code/cli/tests/test_session_store.py +67 -0
  125. kolega_code/cli/tests/test_settings.py +62 -0
  126. kolega_code/cli/tests/test_skills.py +157 -0
  127. kolega_code/cli/tests/test_slash_commands.py +88 -0
  128. kolega_code/cli/theme.py +180 -0
  129. kolega_code/config.py +154 -0
  130. kolega_code/events.py +202 -0
  131. kolega_code/llm/client.py +300 -0
  132. kolega_code/llm/exceptions.py +285 -0
  133. kolega_code/llm/instrumented_client.py +520 -0
  134. kolega_code/llm/models.py +1368 -0
  135. kolega_code/llm/providers/__init__.py +0 -0
  136. kolega_code/llm/providers/anthropic.py +387 -0
  137. kolega_code/llm/providers/base.py +71 -0
  138. kolega_code/llm/providers/google.py +157 -0
  139. kolega_code/llm/providers/models.py +37 -0
  140. kolega_code/llm/providers/openai.py +363 -0
  141. kolega_code/llm/ratelimit.py +40 -0
  142. kolega_code/llm/specs.py +67 -0
  143. kolega_code/llm/tool_execution_ids.py +18 -0
  144. kolega_code/models/__init__.py +9 -0
  145. kolega_code/models/sandbox_terminal_state.py +47 -0
  146. kolega_code/runtime.py +50 -0
  147. kolega_code/sandbox/README.md +200 -0
  148. kolega_code/sandbox/__init__.py +21 -0
  149. kolega_code/sandbox/async_filesystem.py +475 -0
  150. kolega_code/sandbox/base.py +297 -0
  151. kolega_code/sandbox/browser.py +25 -0
  152. kolega_code/sandbox/event_loop.py +43 -0
  153. kolega_code/sandbox/filesystem.py +341 -0
  154. kolega_code/sandbox/local.py +118 -0
  155. kolega_code/sandbox/serializer.py +175 -0
  156. kolega_code/sandbox/terminal.py +868 -0
  157. kolega_code/sandbox/utils.py +216 -0
  158. kolega_code/services/base.py +255 -0
  159. kolega_code/services/browser.py +444 -0
  160. kolega_code/services/file_system.py +749 -0
  161. kolega_code/services/html.py +221 -0
  162. kolega_code/services/terminal.py +903 -0
  163. kolega_code/tools/__init__.py +22 -0
  164. kolega_code/tools/core.py +33 -0
  165. kolega_code/tools/definitions.py +81 -0
  166. kolega_code/tools/registry.py +73 -0
  167. kolega_code-0.1.0.dist-info/METADATA +157 -0
  168. kolega_code-0.1.0.dist-info/RECORD +171 -0
  169. kolega_code-0.1.0.dist-info/WHEEL +4 -0
  170. kolega_code-0.1.0.dist-info/entry_points.txt +2 -0
  171. kolega_code-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,211 @@
1
+ from .. import prompts
2
+ from kolega_code.llm.client import LLMClient
3
+ from kolega_code.llm.instrumented_client import InstrumentedLLMClient
4
+ from kolega_code.llm.models import Message, MessageHistory, TextBlock, ThinkingBlock
5
+ from kolega_code.llm.specs import get_model_specs
6
+ from .streaming_tool import StreamingTool
7
+
8
+
9
+ class ThinkHardTool(StreamingTool):
10
+ async def think_hard(self, problem_statement: str) -> str:
11
+ """
12
+ Uses Claude 3.7 Sonnet in extended thinking mode to analyze a problem deeply.
13
+
14
+ This tool leverages Claude's extended thinking capabilities to perform in-depth
15
+ analysis on complex problems. It sends the problem statement to the Claude API
16
+ with specific parameters to enable extended thinking and returns the detailed response.
17
+
18
+ Args:
19
+ problem_statement: A clear statement of the problem to be analyzed, including ALL relevant details.
20
+
21
+ Returns:
22
+ The detailed analysis from Claude, including its extended thinking process
23
+ """
24
+ await self.log_info(f"Thinking hard about: {problem_statement[:100]}...", sender=self.caller.agent_name)
25
+
26
+ provider = self.config.thinking_config.provider
27
+ api_key = self.config.get_api_key(provider)
28
+ rate_limits = self.config.thinking_config.rate_limits
29
+
30
+ # Check if the caller has an instrumented client we can leverage
31
+ if hasattr(self.caller, "llm") and isinstance(self.caller.llm, InstrumentedLLMClient):
32
+ # Create a new instrumented client with the same Langfuse instance but for thinking
33
+ client = InstrumentedLLMClient(
34
+ provider=provider.value,
35
+ api_key=api_key,
36
+ max_retries=rate_limits.max_retries,
37
+ requests_per_minute=rate_limits.requests_per_minute,
38
+ tokens_per_minute=rate_limits.tokens_per_minute,
39
+ langfuse_client=self.caller.llm.langfuse,
40
+ workspace_id=self.caller.workspace_id,
41
+ thread_id=self.caller.thread_id,
42
+ agent_type=f"{self.caller.agent_name}-thinking",
43
+ environment=self.caller.llm.environment,
44
+ user_id=self.caller.user_id,
45
+ user_email=self.caller.user_email,
46
+ )
47
+ else:
48
+ # Fallback to regular client
49
+ client = LLMClient(
50
+ provider=provider.value,
51
+ api_key=api_key,
52
+ max_retries=rate_limits.max_retries,
53
+ requests_per_minute=rate_limits.requests_per_minute,
54
+ tokens_per_minute=rate_limits.tokens_per_minute,
55
+ )
56
+
57
+ try:
58
+ model_specs = get_model_specs(self.config.thinking_config.provider, self.config.thinking_config.model)
59
+ # Call LLM with extended thinking enabled
60
+ thinking_param = self.config.thinking_config.thinking_tokens
61
+
62
+ system_message = Message(role="system", content=[TextBlock(text=prompts.THINK_HARD_PROMPT)])
63
+
64
+ messages = MessageHistory(
65
+ [
66
+ Message(
67
+ role="user",
68
+ content=[
69
+ TextBlock(
70
+ text=f"Think deeply and comprehensively about this problem:\n\n{problem_statement}"
71
+ )
72
+ ],
73
+ )
74
+ ]
75
+ )
76
+
77
+ # Get tool_call_id from caller if available for streaming
78
+ tool_call_id = getattr(self.caller, "current_tool_call_id", None)
79
+
80
+ # Use streaming to avoid timeout issues
81
+ thinking_content = []
82
+ text_content = []
83
+ accumulated_thinking = ""
84
+ accumulated_text = ""
85
+ has_sent_thinking_header = False
86
+ has_sent_analysis_header = False
87
+
88
+ # Ensure max_completion_tokens is greater than thinking_tokens
89
+ # According to Anthropic docs: max_tokens must be greater than thinking.budget_tokens
90
+ max_completion = model_specs["max_completion_tokens"]
91
+ if thinking_param and max_completion <= thinking_param:
92
+ # Add some buffer to ensure we have room for the actual response
93
+ max_completion = thinking_param + 2000
94
+ await self.log_info(
95
+ f"Adjusted max_completion_tokens from {model_specs['max_completion_tokens']} to {max_completion} "
96
+ f"to accommodate thinking_tokens of {thinking_param}",
97
+ sender=self.caller.agent_name,
98
+ )
99
+
100
+ # Use the stream and process chunks for streaming updates
101
+ async with await client.stream(
102
+ model=self.config.thinking_config.model,
103
+ max_completion_tokens=max_completion,
104
+ system=system_message,
105
+ messages=messages,
106
+ thinking=thinking_param,
107
+ ) as stream:
108
+ # Process chunks for streaming if we have a tool_call_id
109
+ if tool_call_id:
110
+ async for chunk in stream:
111
+ # Check if this is a thinking chunk
112
+ if hasattr(chunk, "thinking") and chunk.thinking:
113
+ # Send header if first thinking content
114
+ if not has_sent_thinking_header:
115
+ await self.send_streaming_update(
116
+ "# Extended Thinking Process\n\n",
117
+ tool_call_id,
118
+ "think_hard",
119
+ is_complete=False,
120
+ stream_mode="append",
121
+ )
122
+ has_sent_thinking_header = True
123
+
124
+ accumulated_thinking += chunk.thinking
125
+ # Stream thinking content periodically
126
+ if len(accumulated_thinking) >= 50:
127
+ await self.send_streaming_update(
128
+ accumulated_thinking,
129
+ tool_call_id,
130
+ "think_hard",
131
+ is_complete=False,
132
+ stream_mode="append",
133
+ )
134
+ accumulated_thinking = ""
135
+
136
+ # Check if this is a text chunk
137
+ elif hasattr(chunk, "text") and chunk.text:
138
+ # Send any remaining thinking content and analysis header
139
+ if accumulated_thinking:
140
+ await self.send_streaming_update(
141
+ accumulated_thinking + "\n\n",
142
+ tool_call_id,
143
+ "think_hard",
144
+ is_complete=False,
145
+ stream_mode="append",
146
+ )
147
+ accumulated_thinking = ""
148
+
149
+ if not has_sent_analysis_header:
150
+ await self.send_streaming_update(
151
+ "# Final Analysis\n\n",
152
+ tool_call_id,
153
+ "think_hard",
154
+ is_complete=False,
155
+ stream_mode="append",
156
+ )
157
+ has_sent_analysis_header = True
158
+
159
+ accumulated_text += chunk.text
160
+ # Stream text content periodically
161
+ if len(accumulated_text) >= 50:
162
+ await self.send_streaming_update(
163
+ accumulated_text,
164
+ tool_call_id,
165
+ "think_hard",
166
+ is_complete=False,
167
+ stream_mode="append",
168
+ )
169
+ accumulated_text = ""
170
+
171
+ # Send any remaining accumulated content
172
+ remaining_content = accumulated_thinking + accumulated_text
173
+ if remaining_content:
174
+ await self.send_streaming_update(
175
+ remaining_content,
176
+ tool_call_id,
177
+ "think_hard",
178
+ is_complete=False,
179
+ stream_mode="append",
180
+ )
181
+
182
+ # Get the final message regardless of streaming
183
+ final_message = await stream.get_final_message()
184
+
185
+ # Extract thinking and text content from the final message
186
+ for block in final_message.content:
187
+ if isinstance(block, ThinkingBlock):
188
+ thinking_content.append(block.thinking)
189
+ elif isinstance(block, TextBlock):
190
+ text_content.append(block.text)
191
+
192
+ # Build the complete result
193
+ result = ""
194
+ if thinking_content:
195
+ result += "# Extended Thinking Process\n\n"
196
+ result += "\n".join(thinking_content) + "\n\n"
197
+ result += "# Final Analysis\n\n"
198
+ result += "\n".join(text_content)
199
+
200
+ # Send final complete update if streaming
201
+ if tool_call_id:
202
+ await self.send_streaming_update(
203
+ result, tool_call_id, "think_hard", is_complete=True, stream_mode="replace"
204
+ )
205
+
206
+ return result
207
+
208
+ except Exception as e:
209
+ error_message = f"Error during extended thinking: {str(e)}"
210
+ await self.log_error(error_message, sender=self.caller.agent_name)
211
+ return error_message
@@ -0,0 +1,205 @@
1
+ import asyncio
2
+ from pathlib import Path
3
+ from typing import Union
4
+
5
+ import trafilatura
6
+
7
+ from kolega_code.config import AgentConfig
8
+ from kolega_code.llm.client import LLMClient
9
+ from kolega_code.llm.instrumented_client import InstrumentedLLMClient
10
+ from kolega_code.llm.models import Message, MessageHistory, TextBlock
11
+ from kolega_code.llm.specs import get_model_specs
12
+ from .streaming_tool import StreamingTool
13
+
14
+
15
+ class WebFetchTool(StreamingTool):
16
+ """Tool for fetching web page content and delegating lightweight processing to the fast model."""
17
+
18
+ FETCH_TIMEOUT_SECONDS = 20
19
+ MAX_CONTENT_CHARS = 100_000
20
+ DEFAULT_RESPONSE_CHAR_LIMIT = 512
21
+ WEB_FETCH_MAX_COMPLETION_TOKENS = 4096
22
+
23
+ def __init__(
24
+ self,
25
+ project_path: Union[str, Path],
26
+ workspace_id: str,
27
+ thread_id: str,
28
+ connection_manager,
29
+ config: AgentConfig,
30
+ caller,
31
+ filesystem=None,
32
+ ):
33
+ super().__init__(project_path, workspace_id, thread_id, connection_manager, config, caller, filesystem)
34
+
35
+ async def web_fetch(self, url: str, instruction: str) -> str:
36
+ """
37
+ Fetch web content from a URL, process it with the fast model, and return a concise answer.
38
+
39
+ This tool downloads the page, extracts clean text via Trafilatura, and asks the fast LLM
40
+ to follow the provided instruction. The model is asked to keep the output compact (≈512
41
+ characters), but the result is only trimmed if it well exceeds that limit.
42
+
43
+ Args:
44
+ url: Fully qualified URL to fetch (http/https).
45
+ instruction: Guidance for how the extracted content should be used.
46
+
47
+ Returns:
48
+ The model's response derived from the fetched content, truncated to the character limit if necessary.
49
+ """
50
+ if not url or not url.lower().startswith(("http://", "https://")):
51
+ return "Error: Provide a valid http(s) URL."
52
+
53
+ tool_call_id = getattr(self.caller, "current_tool_call_id", None)
54
+
55
+ if tool_call_id:
56
+ await self.send_streaming_update(
57
+ f"Fetching content from {url}...", tool_call_id, "web_fetch", is_complete=False
58
+ )
59
+
60
+ try:
61
+ downloaded_html = await asyncio.wait_for(
62
+ asyncio.to_thread(trafilatura.fetch_url, url), timeout=self.FETCH_TIMEOUT_SECONDS
63
+ )
64
+ except asyncio.TimeoutError:
65
+ error_message = f"Error: Timed out fetching {url} after {self.FETCH_TIMEOUT_SECONDS} seconds."
66
+ if tool_call_id:
67
+ await self.send_streaming_update(error_message, tool_call_id, "web_fetch", is_complete=True)
68
+ return error_message
69
+ except Exception as exc: # pragma: no cover - defensive logging branch
70
+ error_message = f"Error: Failed to fetch {url}: {exc}"
71
+ if tool_call_id:
72
+ await self.send_streaming_update(error_message, tool_call_id, "web_fetch", is_complete=True)
73
+ return error_message
74
+
75
+ if not downloaded_html:
76
+ message = f"Error: No content retrieved from {url}."
77
+ if tool_call_id:
78
+ await self.send_streaming_update(message, tool_call_id, "web_fetch", is_complete=True)
79
+ return message
80
+
81
+ try:
82
+ extracted_text = await asyncio.to_thread(
83
+ trafilatura.extract,
84
+ downloaded_html,
85
+ include_comments=False,
86
+ include_tables=True,
87
+ )
88
+ except Exception as exc: # pragma: no cover - defensive logging branch
89
+ error_message = f"Error: Failed to extract content from {url}: {exc}"
90
+ if tool_call_id:
91
+ await self.send_streaming_update(error_message, tool_call_id, "web_fetch", is_complete=True)
92
+ return error_message
93
+
94
+ if not extracted_text or not extracted_text.strip():
95
+ message = f"Error: Extracted page content for {url} is empty."
96
+ if tool_call_id:
97
+ await self.send_streaming_update(message, tool_call_id, "web_fetch", is_complete=True)
98
+ return message
99
+
100
+ content = extracted_text.strip()
101
+ truncated_note = ""
102
+ if len(content) > self.MAX_CONTENT_CHARS:
103
+ content = content[: self.MAX_CONTENT_CHARS]
104
+ truncated_note = (
105
+ f"\n\n[Web content truncated to first {self.MAX_CONTENT_CHARS} characters to fit token limits.]"
106
+ )
107
+
108
+ if tool_call_id:
109
+ await self.send_streaming_update(
110
+ "Processing content with fast model...", tool_call_id, "web_fetch", is_complete=False
111
+ )
112
+
113
+ provider = self.config.fast_config.provider
114
+ api_key = self.config.get_api_key(provider)
115
+ rate_limits = self.config.fast_config.rate_limits
116
+
117
+ client_kwargs = {
118
+ "provider": provider.value,
119
+ "api_key": api_key,
120
+ "max_retries": rate_limits.max_retries,
121
+ "requests_per_minute": rate_limits.requests_per_minute,
122
+ "tokens_per_minute": rate_limits.tokens_per_minute,
123
+ }
124
+
125
+ if hasattr(self.caller, "llm") and isinstance(self.caller.llm, InstrumentedLLMClient):
126
+ client = InstrumentedLLMClient(
127
+ langfuse_client=self.caller.llm.langfuse,
128
+ workspace_id=getattr(self.caller, "workspace_id", None),
129
+ thread_id=getattr(self.caller, "thread_id", None),
130
+ agent_type=f"{self.caller.agent_name}-web-fetch",
131
+ environment=self.config.environment,
132
+ user_id=getattr(self.caller, "user_id", None),
133
+ user_email=getattr(self.caller, "user_email", None),
134
+ **client_kwargs,
135
+ )
136
+ else:
137
+ client = LLMClient(**client_kwargs)
138
+
139
+ try:
140
+ model_specs = get_model_specs(provider, self.config.fast_config.model)
141
+ max_completion_tokens = min(
142
+ int(model_specs["max_completion_tokens"]),
143
+ self.WEB_FETCH_MAX_COMPLETION_TOKENS,
144
+ )
145
+
146
+ target_chars = self.DEFAULT_RESPONSE_CHAR_LIMIT
147
+
148
+ system_prompt = Message(
149
+ role="system",
150
+ content=[
151
+ TextBlock(
152
+ text=(
153
+ "You see extracted web page content and an instruction. Follow the instruction faithfully"
154
+ f" and keep the response around {target_chars} characters when possible—concise but clear."
155
+ " If more detail is required, stay well-structured and call out when the content is"
156
+ " insufficient."
157
+ )
158
+ )
159
+ ],
160
+ )
161
+
162
+ user_prompt = Message(
163
+ role="user",
164
+ content=[
165
+ TextBlock(
166
+ text=f"Instruction:\n{instruction.strip()}\n\nWeb content:\n{content}{truncated_note}"
167
+ )
168
+ ],
169
+ )
170
+
171
+ response_message = await client.generate(
172
+ model=self.config.fast_config.model,
173
+ max_completion_tokens=max_completion_tokens,
174
+ system=system_prompt,
175
+ messages=MessageHistory([user_prompt]),
176
+ temperature=0.0,
177
+ )
178
+
179
+ response_text = (response_message.get_text_content() or "").strip()
180
+ if not response_text:
181
+ error_message = "Error: Fast model returned an empty response for fetched content."
182
+ if tool_call_id:
183
+ await self.send_streaming_update(error_message, tool_call_id, "web_fetch", is_complete=True)
184
+ return error_message
185
+
186
+ hard_cut_threshold = target_chars * 2
187
+ if len(response_text) > hard_cut_threshold:
188
+ # Prefer trimming on word boundaries to avoid mid-word truncation.
189
+ trimmed = response_text[:target_chars].rstrip()
190
+ cut_index = trimmed.rfind(" ")
191
+ if cut_index > 0:
192
+ trimmed = trimmed[:cut_index]
193
+ if not trimmed:
194
+ trimmed = response_text[:target_chars]
195
+ response_text = trimmed.rstrip(" ,.;:-") + "…"
196
+
197
+ if tool_call_id:
198
+ await self.send_streaming_update(response_text, tool_call_id, "web_fetch", is_complete=True)
199
+
200
+ return response_text
201
+ except Exception as exc:
202
+ error_message = f"Error: Failed to process content with fast model: {exc}"
203
+ if tool_call_id:
204
+ await self.send_streaming_update(error_message, tool_call_id, "web_fetch", is_complete=True)
205
+ return error_message