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