srcodex 0.2.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.
- srcodex/__init__.py +0 -0
- srcodex/backend/__init__.py +0 -0
- srcodex/backend/chat.py +79 -0
- srcodex/backend/main.py +98 -0
- srcodex/backend/services/__init__.py +0 -0
- srcodex/backend/services/claude_service.py +754 -0
- srcodex/backend/services/config_loader.py +113 -0
- srcodex/backend/services/file_access_tools.py +279 -0
- srcodex/backend/services/file_tree.py +480 -0
- srcodex/backend/services/graph_tools.py +874 -0
- srcodex/backend/services/logger_setup.py +91 -0
- srcodex/backend/services/session_manager.py +81 -0
- srcodex/backend/services/status_tracker.py +91 -0
- srcodex/cli.py +255 -0
- srcodex/core/__init__.py +0 -0
- srcodex/core/config.py +113 -0
- srcodex/core/logger.py +23 -0
- srcodex/indexer/__init__.py +0 -0
- srcodex/indexer/cscope_client.py +183 -0
- srcodex/indexer/ctags_compat.py +223 -0
- srcodex/indexer/ctags_parser.py +456 -0
- srcodex/indexer/explorer.py +135 -0
- srcodex/indexer/field_access_analyzer.py +436 -0
- srcodex/indexer/indexer.py +664 -0
- srcodex/indexer/reference_ingestor.py +293 -0
- srcodex/indexer/reference_resolver.py +544 -0
- srcodex/tui/__init__.py +0 -0
- srcodex/tui/app.py +103 -0
- srcodex/tui/app.tcss +24 -0
- srcodex/tui/components/__init__.py +0 -0
- srcodex/tui/components/bars/__init__.py +0 -0
- srcodex/tui/components/bars/chat_header.py +48 -0
- srcodex/tui/components/bars/code_tab_bar.py +157 -0
- srcodex/tui/components/bars/footer_bar.py +128 -0
- srcodex/tui/components/bars/left_tab.py +54 -0
- srcodex/tui/components/logger.py +57 -0
- srcodex/tui/components/panels/__init__.py +0 -0
- srcodex/tui/components/panels/chat_panel.py +523 -0
- srcodex/tui/components/panels/code_panel.py +229 -0
- srcodex/tui/components/panels/side_panel.py +128 -0
- srcodex/tui/components/views/__init__.py +0 -0
- srcodex/tui/components/views/explorer_view.py +20 -0
- srcodex/tui/components/views/search_view.py +148 -0
- srcodex/tui/components/widgets/__init__.py +0 -0
- srcodex/tui/components/widgets/file_browser.py +16 -0
- srcodex/tui/components/widgets/find_box.py +85 -0
- srcodex-0.2.0.dist-info/METADATA +170 -0
- srcodex-0.2.0.dist-info/RECORD +52 -0
- srcodex-0.2.0.dist-info/WHEEL +5 -0
- srcodex-0.2.0.dist-info/entry_points.txt +2 -0
- srcodex-0.2.0.dist-info/licenses/LICENSE +21 -0
- srcodex-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import logging
|
|
3
|
+
from anthropic import Anthropic, APIError, APIStatusError
|
|
4
|
+
from .file_access_tools import TOOL_DEFINITIONS as FILE_TOOLS, execute_tool as execute_file_tool
|
|
5
|
+
from .graph_tools import TOOLS as GRAPH_TOOLS, execute_graph_tool
|
|
6
|
+
from .config_loader import get_config
|
|
7
|
+
from .status_tracker import StatusTracker
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ClaudeService:
|
|
14
|
+
"""Wrapper for Claude API - supports both AMD LLM Gateway and public Anthropic API"""
|
|
15
|
+
def __init__(self):
|
|
16
|
+
amd_api_key = os.getenv("AMD_LLM_API_KEY")
|
|
17
|
+
anthropic_api_key = os.getenv("ANTHROPIC_API_KEY")
|
|
18
|
+
|
|
19
|
+
if amd_api_key and amd_api_key != "dummy":
|
|
20
|
+
# AMD LLM Gateway mode
|
|
21
|
+
base_url = os.getenv("ANTHROPIC_BASE_URL", "https://llm-api.amd.com/Anthropic")
|
|
22
|
+
self.client = Anthropic(
|
|
23
|
+
base_url=base_url,
|
|
24
|
+
api_key="dummy",
|
|
25
|
+
default_headers={
|
|
26
|
+
"Ocp-Apim-Subscription-Key": amd_api_key,
|
|
27
|
+
"user": os.getenv("USER", "unknown")
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
self.model = os.getenv("ANTHROPIC_DEFAULT_SONNET_MODEL", "claude-sonnet-4.5")
|
|
31
|
+
logger.info("Using AMD LLM Gateway")
|
|
32
|
+
|
|
33
|
+
elif anthropic_api_key:
|
|
34
|
+
# Public Anthropic API mode
|
|
35
|
+
base_url = os.getenv("ANTHROPIC_BASE_URL", "https://api.anthropic.com")
|
|
36
|
+
self.client = Anthropic(
|
|
37
|
+
base_url=base_url,
|
|
38
|
+
api_key=anthropic_api_key
|
|
39
|
+
)
|
|
40
|
+
self.model = os.getenv("ANTHROPIC_DEFAULT_SONNET_MODEL", "claude-sonnet-4-20250514")
|
|
41
|
+
logger.info("Using public Anthropic API")
|
|
42
|
+
|
|
43
|
+
else:
|
|
44
|
+
raise ValueError(
|
|
45
|
+
"No API key found! Set either:\n"
|
|
46
|
+
" - AMD_LLM_API_KEY (for AMD internal users)\n"
|
|
47
|
+
" - ANTHROPIC_API_KEY (for public API users)"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Merge all tools (file tools + graph tools)
|
|
51
|
+
self.tools = FILE_TOOLS + GRAPH_TOOLS
|
|
52
|
+
|
|
53
|
+
# Load project configuration and generate system prompt
|
|
54
|
+
config = get_config()
|
|
55
|
+
stats = config.stats
|
|
56
|
+
|
|
57
|
+
# System prompt with project context (auto-generated from metadata)
|
|
58
|
+
self.system_prompt = f""" CORE PRINCIPLE: THINK AHEAD, BATCH AGGRESSIVELY
|
|
59
|
+
|
|
60
|
+
Before calling ANY tools, think: "What will I need in the NEXT iteration? Fetch it ALL NOW!"
|
|
61
|
+
|
|
62
|
+
You are analyzing the {config.project_name} project.
|
|
63
|
+
|
|
64
|
+
**Project Context:**
|
|
65
|
+
- Source root: {config.metadata['paths']['source_root']}/ (all paths are relative to this)
|
|
66
|
+
- Files indexed: {stats['files_indexed']:,}
|
|
67
|
+
- Total symbols: {stats['total_symbols']:,}
|
|
68
|
+
- Call graph edges: {stats['edges']['calls']:,} CALLS relationships
|
|
69
|
+
- Include edges: {stats['edges']['includes']:,} INCLUDES relationships
|
|
70
|
+
- Field access edges: {stats['edges']['accesses']:,} ACCESSES relationships
|
|
71
|
+
|
|
72
|
+
**Path Convention:**
|
|
73
|
+
All file paths are relative to source root. Examples:
|
|
74
|
+
- 'firmware/main/mp1/src/app/power.c'
|
|
75
|
+
- 'firmware/main/mpccx/src/app/thermal.c'
|
|
76
|
+
|
|
77
|
+
**Available Tools:**
|
|
78
|
+
|
|
79
|
+
File System Tools:
|
|
80
|
+
- read_file(file_path): Read source code files (path relative to source root)
|
|
81
|
+
- list_directory(dir_path): Browse directory structure (path relative to source root)
|
|
82
|
+
- search_files(pattern, search_path): Find files by glob pattern
|
|
83
|
+
|
|
84
|
+
Semantic Graph Tools (use these to save tokens!):
|
|
85
|
+
- get_callers: Find what calls a function (1-hop backward)
|
|
86
|
+
- get_callees: Find what a function calls (1-hop forward)
|
|
87
|
+
- get_call_chain: Trace execution paths from A to B (multi-hop)
|
|
88
|
+
- search_symbols: Search for symbols by name pattern
|
|
89
|
+
- get_symbol_definition: Get ONLY one symbol's definition (not entire file)
|
|
90
|
+
- get_symbols_from_file: Get ALL symbols from a file (replaces read_file for headers)
|
|
91
|
+
- get_file_by_pattern: Find files by name pattern
|
|
92
|
+
- execute_sql: Custom SQL queries on the semantic graph
|
|
93
|
+
|
|
94
|
+
**Database Schema (for execute_sql):**
|
|
95
|
+
|
|
96
|
+
symbols table:
|
|
97
|
+
- id, name, type (function/struct/macro/variable/enum/typedef)
|
|
98
|
+
- file_path, line_number, signature
|
|
99
|
+
- scope_kind, scope_name (parent scope)
|
|
100
|
+
|
|
101
|
+
symbol_edges table:
|
|
102
|
+
- edge_type ('CALLS', 'INCLUDES', 'ACCESSES')
|
|
103
|
+
- src_symbol_id, dst_symbol_id (foreign keys to symbols.id)
|
|
104
|
+
- source_file, line_number (where edge occurs)
|
|
105
|
+
|
|
106
|
+
Example SQL:
|
|
107
|
+
SELECT s1.name as caller, s2.name as callee
|
|
108
|
+
FROM symbol_edges e
|
|
109
|
+
JOIN symbols s1 ON e.src_symbol_id = s1.id
|
|
110
|
+
JOIN symbols s2 ON e.dst_symbol_id = s2.id
|
|
111
|
+
WHERE e.edge_type = 'CALLS' AND s2.name = 'FunctionName'
|
|
112
|
+
|
|
113
|
+
WARN: WARN: WARN: CRITICAL: TARGET 3 ITERATIONS (4 iterations MAX) WARN: WARN: WARN:
|
|
114
|
+
|
|
115
|
+
**WHY 3 ITERATIONS?**
|
|
116
|
+
- Iterations 1-3 are CACHED (free to access later)
|
|
117
|
+
- Iteration 4+ is NOT CACHED (every tool result costs tokens)
|
|
118
|
+
- Solution: Get EVERYTHING in iterations 1-3, then answer in iteration 4
|
|
119
|
+
|
|
120
|
+
**THINK AHEAD! Predict what you'll need in future iterations and fetch it NOW!**
|
|
121
|
+
|
|
122
|
+
**MANDATORY ITERATION PLAN:**
|
|
123
|
+
|
|
124
|
+
**Iteration 1 (BROAD EXPLORATION - 15-25 tools):**
|
|
125
|
+
Think: "What are ALL the patterns, files, and areas I might need to explore?"
|
|
126
|
+
Then call EVERY exploration tool in ONE batch:
|
|
127
|
+
- search_symbols() with 5-10 different patterns ('%foo%', '%bar%', '%init%', '%process%', etc.)
|
|
128
|
+
- execute_sql() for 3-5 aggregate queries (file counts, symbol types, etc.)
|
|
129
|
+
- get_symbols_from_file() for 5-10 key files you predict will matter
|
|
130
|
+
- list_indexed_files() if exploring file structure
|
|
131
|
+
**THINK PREDICTIVELY:** If the question is "how does X work?", you'll need X's definition, callees, callers, related files - so search for ALL of those patterns NOW!
|
|
132
|
+
|
|
133
|
+
**Iteration 2 (FETCH EVERYTHING - 20-30 tools):**
|
|
134
|
+
Think: "From iteration 1, what are ALL the symbols/functions I found? I'll need ALL their details!"
|
|
135
|
+
Then fetch EVERYTHING in ONE batch:
|
|
136
|
+
- get_symbol_definition() for EVERY relevant symbol (15-25 symbols, not just 3-4!)
|
|
137
|
+
- get_callees() for EVERY function found
|
|
138
|
+
- get_callers() for EVERY function found
|
|
139
|
+
- execute_sql() for relationships between symbols
|
|
140
|
+
**BE GREEDY:** If iteration 1 found 20 symbols, fetch ALL 20 definitions NOW! Don't cherry-pick 5 and come back later!
|
|
141
|
+
|
|
142
|
+
**Iteration 3 (DEEP DIVE - 10-20 tools, LAST CACHED ITERATION!):**
|
|
143
|
+
Think: "What are ALL the remaining details I need to answer completely?"
|
|
144
|
+
WARN: THIS IS YOUR LAST CACHED ITERATION! Get EVERYTHING you need NOW!
|
|
145
|
+
- get_symbol_definition() with context_lines=20 for ALL core symbols
|
|
146
|
+
- get_call_chain() for ALL execution paths
|
|
147
|
+
- execute_sql() for ALL complex relationship queries
|
|
148
|
+
- get_symbols_from_file() with include_definitions=True for ALL critical files
|
|
149
|
+
**CRITICAL:** If you're missing ANYTHING, fetch it NOW! Iteration 4 is NOT cached - every tool wastes tokens!
|
|
150
|
+
|
|
151
|
+
**Iteration 4 (ANSWER - ZERO tools):**
|
|
152
|
+
Synthesize everything from iterations 1-3 into your complete answer.
|
|
153
|
+
WARN: DO NOT call tools in iteration 4 - they're not cached and waste tokens!
|
|
154
|
+
You have ALL the information from iterations 1-3 (cached). Use it to answer fully.
|
|
155
|
+
|
|
156
|
+
**Iterations 5-6 (EMERGENCY FALLBACK - SHOULD NOT REACH):**
|
|
157
|
+
You failed to complete in 4 iterations. Answer with what you have.
|
|
158
|
+
|
|
159
|
+
**EXAMPLES:**
|
|
160
|
+
|
|
161
|
+
PERFECT (4 iterations):
|
|
162
|
+
Q: "How does the indexer work?"
|
|
163
|
+
Iteration 1: [search_symbols('%index%'), search_symbols('%parse%'), search_symbols('%ctags%'),
|
|
164
|
+
execute_sql("SELECT * FROM symbols WHERE name LIKE '%index%'"),
|
|
165
|
+
execute_sql("SELECT * FROM symbols WHERE type='class'"),
|
|
166
|
+
get_symbols_from_file('indexer/indexer.py'),
|
|
167
|
+
get_symbols_from_file('indexer/ctags_parser.py'),
|
|
168
|
+
... 40 more tools] (50 tools total)
|
|
169
|
+
Iteration 2: [get_symbol_definition('Indexer'), get_symbol_definition('parse_symbols'),
|
|
170
|
+
get_callees('index_directory'), get_callers('parse_symbols'),
|
|
171
|
+
... 45 more tools] (60 tools total)
|
|
172
|
+
Iteration 3: [execute_sql("SELECT * FROM symbol_edges WHERE src_symbol_id=123"),
|
|
173
|
+
get_call_chain('main', 'parse_symbols'), ... 15 more tools] (20 tools total)
|
|
174
|
+
Iteration 4: "The indexer works in 3 stages..." (ANSWER, 0 tools)
|
|
175
|
+
|
|
176
|
+
ERROR: FAILURE (6+ iterations):
|
|
177
|
+
Iteration 1: 5 tools
|
|
178
|
+
Iteration 2: 3 tools
|
|
179
|
+
Iteration 3: 4 tools
|
|
180
|
+
... YOU FAILED. Start over and batch properly!
|
|
181
|
+
|
|
182
|
+
**If you call fewer than 20 tools in iterations 1-2, you are doing it WRONG.**
|
|
183
|
+
|
|
184
|
+
**IMPORTANT: NEVER mention iterations, caching, or your tool-gathering strategy in your final answer.**
|
|
185
|
+
The user doesn't need to know about your internal process. Just answer their question directly and professionally.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
def _truncate_conversation_history(self, conversation_history, max_messages=10):
|
|
189
|
+
"""Keep last N messages for cache efficiency"""
|
|
190
|
+
if len(conversation_history) > max_messages:
|
|
191
|
+
return conversation_history[-max_messages:]
|
|
192
|
+
return conversation_history
|
|
193
|
+
|
|
194
|
+
def _calculate_savings(self, user_input_tokens, files_accessed_count):
|
|
195
|
+
"""
|
|
196
|
+
Calculate token savings vs traditional manual approach
|
|
197
|
+
|
|
198
|
+
Traditional: User pastes N files × avg_lines × tokens_per_line
|
|
199
|
+
srcodex: User types short queries, tools fetch only needed data
|
|
200
|
+
|
|
201
|
+
Returns: (traditional_tokens, savings_percentage)
|
|
202
|
+
"""
|
|
203
|
+
config = get_config()
|
|
204
|
+
|
|
205
|
+
# Get average file size from metadata
|
|
206
|
+
avg_lines_per_file = config.stats.get('avg_lines_per_file', 500)
|
|
207
|
+
tokens_per_line = 4 # Industry standard
|
|
208
|
+
|
|
209
|
+
# Traditional approach: paste entire files
|
|
210
|
+
traditional_tokens = files_accessed_count * avg_lines_per_file * tokens_per_line
|
|
211
|
+
|
|
212
|
+
# Avoid division by zero
|
|
213
|
+
if traditional_tokens == 0:
|
|
214
|
+
return 0, 0.0
|
|
215
|
+
|
|
216
|
+
# Calculate savings percentage
|
|
217
|
+
savings_percentage = (1 - user_input_tokens / traditional_tokens) * 100
|
|
218
|
+
|
|
219
|
+
# Cap at 99.9% (avoid showing 100%)
|
|
220
|
+
savings_percentage = min(savings_percentage, 99.9)
|
|
221
|
+
|
|
222
|
+
return traditional_tokens, savings_percentage
|
|
223
|
+
|
|
224
|
+
def send_message(self, message):
|
|
225
|
+
"""Send Message to Claude and get response"""
|
|
226
|
+
|
|
227
|
+
response = self.client.messages.create(
|
|
228
|
+
model=self.model,
|
|
229
|
+
max_tokens=8192,
|
|
230
|
+
system=self.system_prompt,
|
|
231
|
+
messages=[
|
|
232
|
+
{"role": "user", "content": message}
|
|
233
|
+
]
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
for block in response.content:
|
|
237
|
+
if block.type == "text":
|
|
238
|
+
return block.text
|
|
239
|
+
return ""
|
|
240
|
+
|
|
241
|
+
def send_message_with_tools(self, message, conversation_history=None):
|
|
242
|
+
"""Send message to Claude with tool support"""
|
|
243
|
+
logger.info("=" * 80)
|
|
244
|
+
logger.info(f"MSG: User message: {message}")
|
|
245
|
+
|
|
246
|
+
# Build messages array with conversation history
|
|
247
|
+
if conversation_history:
|
|
248
|
+
messages = self._truncate_conversation_history(conversation_history)
|
|
249
|
+
logger.info(f"HISTORY: Using conversation history ({len(conversation_history)} messages, truncated to {len(messages)})")
|
|
250
|
+
else:
|
|
251
|
+
messages = []
|
|
252
|
+
|
|
253
|
+
# Add current message
|
|
254
|
+
messages.append({"role": "user", "content": message})
|
|
255
|
+
|
|
256
|
+
# Token tracking
|
|
257
|
+
total_input_tokens = 0
|
|
258
|
+
total_output_tokens = 0
|
|
259
|
+
|
|
260
|
+
# File access tracking for savings calculation
|
|
261
|
+
files_accessed = set()
|
|
262
|
+
|
|
263
|
+
# Cache breakpoint tracking (max 4 total: 1 for system + 3 for messages)
|
|
264
|
+
cache_breakpoints_used = 0
|
|
265
|
+
max_cache_breakpoints = 3
|
|
266
|
+
|
|
267
|
+
# Tool use loop - max 6 iterations (target: 4, absolute emergency: 5)
|
|
268
|
+
iteration = 0
|
|
269
|
+
max_iterations = 6
|
|
270
|
+
while True:
|
|
271
|
+
iteration += 1
|
|
272
|
+
|
|
273
|
+
# At iteration 6, FORCE final answer (disable tools completely)
|
|
274
|
+
if iteration > max_iterations:
|
|
275
|
+
logger.error(f"CRITICAL: ITERATION {iteration} - EXCEEDED MAX! Forcing answer with available context.")
|
|
276
|
+
|
|
277
|
+
# Warnings for iterations past target
|
|
278
|
+
if iteration == 5:
|
|
279
|
+
logger.warning("WARN: ITERATION 5/6 - Should have finished in 4! One more iteration left.")
|
|
280
|
+
elif iteration == 6:
|
|
281
|
+
logger.error("CRITICAL: ITERATION 6/6 - FINAL ITERATION! Must answer NOW.")
|
|
282
|
+
|
|
283
|
+
logger.info(f"\nITER Iteration {iteration}/6: Calling Claude API...")
|
|
284
|
+
|
|
285
|
+
# Build system prompt with cache control
|
|
286
|
+
system_with_cache = [
|
|
287
|
+
{
|
|
288
|
+
"type": "text",
|
|
289
|
+
"text": self.system_prompt,
|
|
290
|
+
"cache_control": {"type": "ephemeral"}
|
|
291
|
+
}
|
|
292
|
+
]
|
|
293
|
+
|
|
294
|
+
# Keep tools constant to preserve cache
|
|
295
|
+
tools_to_use = self.tools
|
|
296
|
+
|
|
297
|
+
# At iteration 6, inject urgent message to FORCE answer without tools
|
|
298
|
+
messages_to_send = messages
|
|
299
|
+
if iteration >= 6:
|
|
300
|
+
# Add urgent instruction as last message
|
|
301
|
+
messages_to_send = messages + [{
|
|
302
|
+
"role": "user",
|
|
303
|
+
"content": "WARN: CRITICAL: This is iteration 6/6. You MUST provide your final answer NOW using everything you've gathered. DO NOT call any more tools. Synthesize your findings and answer the user's question completely."
|
|
304
|
+
}]
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
response = self.client.messages.create(
|
|
308
|
+
model=self.model,
|
|
309
|
+
max_tokens=8192,
|
|
310
|
+
system=system_with_cache,
|
|
311
|
+
tools=tools_to_use,
|
|
312
|
+
messages=messages_to_send,
|
|
313
|
+
extra_headers={
|
|
314
|
+
"anthropic-beta": "context-management-2025-06-27,prompt-caching-2024-07-31"
|
|
315
|
+
}
|
|
316
|
+
)
|
|
317
|
+
except APIStatusError as e:
|
|
318
|
+
logger.error(f"ERROR: API Error: {e.status_code} {e.message}")
|
|
319
|
+
logger.error(f" Response body: {e.body}")
|
|
320
|
+
logger.error(f" Request details:")
|
|
321
|
+
logger.error(f" - Model: {self.model}")
|
|
322
|
+
logger.error(f" - Messages count: {len(messages)}")
|
|
323
|
+
logger.error(f" - Tools count: {len(self.tools)}")
|
|
324
|
+
if messages:
|
|
325
|
+
logger.error(f" - Last message: {messages[-1]}")
|
|
326
|
+
raise
|
|
327
|
+
except APIError as e:
|
|
328
|
+
logger.error(f"ERROR: API Error: {e}")
|
|
329
|
+
raise
|
|
330
|
+
|
|
331
|
+
# Check stop reason
|
|
332
|
+
if response.stop_reason == "end_turn":
|
|
333
|
+
# No more tool calls, return final text
|
|
334
|
+
logger.info(" Claude finished (no more tools)")
|
|
335
|
+
for block in response.content:
|
|
336
|
+
if block.type == "text":
|
|
337
|
+
logger.info(f" Response length: {len(block.text)} chars")
|
|
338
|
+
logger.info("=" * 80)
|
|
339
|
+
return block.text
|
|
340
|
+
return ""
|
|
341
|
+
|
|
342
|
+
elif response.stop_reason == "tool_use":
|
|
343
|
+
# Block tools after iteration 3 (cache is full)
|
|
344
|
+
if iteration > 3:
|
|
345
|
+
logger.error(f"BLOCKED: BLOCKED: Claude tried to call {sum(1 for b in response.content if b.type == 'tool_use')} tools in iteration {iteration}!")
|
|
346
|
+
logger.error(" Tools are ONLY allowed in iterations 1-3 (cached). Forcing answer with cached data.")
|
|
347
|
+
|
|
348
|
+
# Skip appending assistant message with tool_use to avoid API error
|
|
349
|
+
# Inject user message to force answer
|
|
350
|
+
messages.append({
|
|
351
|
+
"role": "user",
|
|
352
|
+
"content": "BLOCKED: TOOL CALLS BLOCKED! You are in iteration 4+. Tools are ONLY allowed in iterations 1-3. You have ALL the data from cached iterations. Provide your complete answer NOW. DO NOT call any more tools."
|
|
353
|
+
})
|
|
354
|
+
# Loop back to get answer
|
|
355
|
+
continue
|
|
356
|
+
|
|
357
|
+
# Claude wants to use tools
|
|
358
|
+
logger.info(" Claude is using tools...")
|
|
359
|
+
|
|
360
|
+
# Add assistant's response to messages
|
|
361
|
+
messages.append({
|
|
362
|
+
"role": "assistant",
|
|
363
|
+
"content": response.content
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
# Execute all tool calls
|
|
367
|
+
tool_results = []
|
|
368
|
+
tool_count = 0
|
|
369
|
+
for block in response.content:
|
|
370
|
+
if block.type == "tool_use":
|
|
371
|
+
tool_count += 1
|
|
372
|
+
logger.info(f"\n Tool Tool #{tool_count}: {block.name}")
|
|
373
|
+
logger.info(f" Input: {block.input}")
|
|
374
|
+
|
|
375
|
+
# Route to correct tool handler
|
|
376
|
+
file_tools = ["read_file", "list_directory", "search_files"]
|
|
377
|
+
graph_tools = ["get_callers", "get_callees", "get_call_chain", "execute_sql",
|
|
378
|
+
"get_file_by_pattern", "get_file_info", "list_indexed_files",
|
|
379
|
+
"search_symbols", "get_symbol_definition", "get_symbols_from_file"]
|
|
380
|
+
|
|
381
|
+
if block.name in file_tools:
|
|
382
|
+
logger.info(f" Type: FILE SYSTEM TOOL")
|
|
383
|
+
result = execute_file_tool(block.name, block.input)
|
|
384
|
+
elif block.name in graph_tools:
|
|
385
|
+
logger.info(f" Type: GRAPH TOOL ")
|
|
386
|
+
result = execute_graph_tool(block.name, block.input)
|
|
387
|
+
else:
|
|
388
|
+
logger.warning(f" Type: UNKNOWN TOOL!")
|
|
389
|
+
result = {"error": f"Unknown tool: {block.name}"}
|
|
390
|
+
|
|
391
|
+
# Track files accessed for savings calculation
|
|
392
|
+
if block.name in ["read_file", "get_symbols_from_file", "get_file_info"]:
|
|
393
|
+
file_path = block.input.get("file_path")
|
|
394
|
+
if file_path:
|
|
395
|
+
files_accessed.add(file_path)
|
|
396
|
+
|
|
397
|
+
# Log result summary
|
|
398
|
+
if isinstance(result, dict):
|
|
399
|
+
if "error" in result:
|
|
400
|
+
logger.error(f" ERROR: Error: {result['error']}")
|
|
401
|
+
elif "count" in result:
|
|
402
|
+
logger.info(f" Returned {result['count']} results")
|
|
403
|
+
else:
|
|
404
|
+
logger.info(f" Success (keys: {list(result.keys())})")
|
|
405
|
+
|
|
406
|
+
# Add tool result (no truncation - iterations 1-3 are cached)
|
|
407
|
+
tool_results.append({
|
|
408
|
+
"type": "tool_result",
|
|
409
|
+
"tool_use_id": block.id,
|
|
410
|
+
"content": str(result)
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
logger.info(f"\n Executed {tool_count} tool(s), sending results back to Claude...")
|
|
414
|
+
|
|
415
|
+
# Send tool results back to Claude
|
|
416
|
+
# Cache tool results if under breakpoint limit (max 4 total: system + 3 messages)
|
|
417
|
+
if tool_results and cache_breakpoints_used < max_cache_breakpoints:
|
|
418
|
+
tool_results[-1]["cache_control"] = {"type": "ephemeral"}
|
|
419
|
+
cache_breakpoints_used += 1
|
|
420
|
+
logger.info(f"CACHE: Cache breakpoint set on last tool result (iteration {iteration}, breakpoint {cache_breakpoints_used}/{max_cache_breakpoints})")
|
|
421
|
+
elif tool_results and cache_breakpoints_used >= max_cache_breakpoints:
|
|
422
|
+
logger.info(f"WARN: Skipping cache (already at {cache_breakpoints_used}/{max_cache_breakpoints} breakpoints) - rely on parallel tools to finish quickly!")
|
|
423
|
+
|
|
424
|
+
messages.append({
|
|
425
|
+
"role": "user",
|
|
426
|
+
"content": tool_results
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
# Continue loop to get Claude's response
|
|
430
|
+
|
|
431
|
+
else:
|
|
432
|
+
# Unexpected stop reason
|
|
433
|
+
return f"Unexpected stop reason: {response.stop_reason}"
|
|
434
|
+
|
|
435
|
+
def stream_message_with_tools(self, message, conversation_history=None):
|
|
436
|
+
"""
|
|
437
|
+
Stream message to Claude with tool support - yields text chunks and metadata
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
message: User's current message
|
|
441
|
+
conversation_history: Optional list of previous messages [{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}]
|
|
442
|
+
|
|
443
|
+
Yields:
|
|
444
|
+
dict: Either text chunks or token metadata
|
|
445
|
+
{"type": "text", "content": "..."}
|
|
446
|
+
{"type": "tokens", "input": 1234, "output": 56, "total": 1290, "cache_read": 100, "cache_write": 50}
|
|
447
|
+
"""
|
|
448
|
+
logger.info("=" * 80)
|
|
449
|
+
logger.info(f"MSG: User message (streaming): {message}")
|
|
450
|
+
|
|
451
|
+
# Initialize status tracker
|
|
452
|
+
status = StatusTracker()
|
|
453
|
+
status.start_query()
|
|
454
|
+
|
|
455
|
+
# Build messages array with conversation history
|
|
456
|
+
if conversation_history:
|
|
457
|
+
messages = self._truncate_conversation_history(conversation_history)
|
|
458
|
+
logger.info(f"HISTORY: Using conversation history ({len(conversation_history)} messages, truncated to {len(messages)})")
|
|
459
|
+
else:
|
|
460
|
+
messages = []
|
|
461
|
+
|
|
462
|
+
# Estimate conversation history tokens BEFORE adding current message
|
|
463
|
+
# Each previous message ~10-50 tokens (use 20 as conservative estimate to avoid going negative)
|
|
464
|
+
conversation_history_tokens = len(messages) * 20
|
|
465
|
+
|
|
466
|
+
# NOTE: Conversation history caching disabled to stay within 4 cache breakpoint limit
|
|
467
|
+
# We use: 1=system, 2=iter1 tools, 3=iter2 tools, 4=iter3 tools
|
|
468
|
+
|
|
469
|
+
# Add current message
|
|
470
|
+
messages.append({"role": "user", "content": message})
|
|
471
|
+
|
|
472
|
+
# Token tracking
|
|
473
|
+
total_input_tokens = 0
|
|
474
|
+
total_output_tokens = 0
|
|
475
|
+
total_cache_read_tokens = 0
|
|
476
|
+
total_cache_write_tokens = 0
|
|
477
|
+
user_message_tokens = 0
|
|
478
|
+
iteration_1_cache_write = 0 # Track iteration 1 cache (system + tools)
|
|
479
|
+
cached_iterations_input = 0 # Track input tokens from iterations 1-3 (when we're caching)
|
|
480
|
+
PROMPT_OVERHEAD = 300 # System prompt tokens (constant across queries)
|
|
481
|
+
|
|
482
|
+
# File access tracking for savings calculation
|
|
483
|
+
files_accessed = set()
|
|
484
|
+
|
|
485
|
+
# Cache breakpoint tracking (max 4 total: 1 for system + 3 for messages)
|
|
486
|
+
cache_breakpoints_used = 0
|
|
487
|
+
max_cache_breakpoints = 3
|
|
488
|
+
|
|
489
|
+
# Tool use loop - max 6 iterations (target: 4)
|
|
490
|
+
iteration = 0
|
|
491
|
+
max_iterations = 6
|
|
492
|
+
while True:
|
|
493
|
+
iteration += 1
|
|
494
|
+
status.start_iteration(iteration)
|
|
495
|
+
|
|
496
|
+
# At iteration 6, FORCE final answer (disable tools completely)
|
|
497
|
+
if iteration > max_iterations:
|
|
498
|
+
logger.error(f"CRITICAL: ITERATION {iteration} - EXCEEDED MAX! Forcing answer with available context.")
|
|
499
|
+
|
|
500
|
+
# Warnings for iterations past target
|
|
501
|
+
if iteration == 5:
|
|
502
|
+
logger.warning("WARN: ITERATION 5/6 - Should have finished in 4! One more iteration left.")
|
|
503
|
+
elif iteration == 6:
|
|
504
|
+
logger.error("CRITICAL: ITERATION 6/6 - FINAL ITERATION! Must answer NOW or fail.")
|
|
505
|
+
|
|
506
|
+
logger.info(f"\nITER Iteration {iteration}/6: Calling Claude API...")
|
|
507
|
+
|
|
508
|
+
# Build system prompt with cache control
|
|
509
|
+
system_with_cache = [
|
|
510
|
+
{
|
|
511
|
+
"type": "text",
|
|
512
|
+
"text": self.system_prompt,
|
|
513
|
+
"cache_control": {"type": "ephemeral"}
|
|
514
|
+
}
|
|
515
|
+
]
|
|
516
|
+
|
|
517
|
+
# Keep tools constant to preserve cache
|
|
518
|
+
tools_with_cache = self.tools
|
|
519
|
+
|
|
520
|
+
# At iteration 6, inject urgent message to FORCE answer without tools
|
|
521
|
+
messages_to_send = messages
|
|
522
|
+
if iteration >= 6:
|
|
523
|
+
# Add urgent instruction as last message (doesn't break cache since it's a NEW iteration)
|
|
524
|
+
messages_to_send = messages + [{
|
|
525
|
+
"role": "user",
|
|
526
|
+
"content": "WARN: CRITICAL: This is iteration 6/6. You MUST provide your final answer NOW using everything you've gathered. DO NOT call any more tools. Synthesize your findings and answer the user's question completely."
|
|
527
|
+
}]
|
|
528
|
+
|
|
529
|
+
try:
|
|
530
|
+
response = self.client.messages.create(
|
|
531
|
+
model=self.model,
|
|
532
|
+
max_tokens=8192,
|
|
533
|
+
system=system_with_cache,
|
|
534
|
+
tools=tools_with_cache,
|
|
535
|
+
messages=messages_to_send
|
|
536
|
+
)
|
|
537
|
+
except APIStatusError as e:
|
|
538
|
+
logger.error(f"ERROR: API Error: {e.status_code} {e.message}")
|
|
539
|
+
logger.error(f" Response body: {e.body}")
|
|
540
|
+
logger.error(f" Request details:")
|
|
541
|
+
logger.error(f" - Model: {self.model}")
|
|
542
|
+
logger.error(f" - Messages count: {len(messages)}")
|
|
543
|
+
logger.error(f" - Tools count: {len(tools_with_cache)}")
|
|
544
|
+
if messages:
|
|
545
|
+
logger.error(f" - Last message: {messages[-1]}")
|
|
546
|
+
# Yield error to frontend
|
|
547
|
+
yield {"type": "error", "content": f"API Error {e.status_code}: {e.message}"}
|
|
548
|
+
return
|
|
549
|
+
except APIError as e:
|
|
550
|
+
logger.error(f"ERROR: API Error: {e}")
|
|
551
|
+
yield {"type": "error", "content": f"API Error: {str(e)}"}
|
|
552
|
+
return
|
|
553
|
+
|
|
554
|
+
# Track tokens
|
|
555
|
+
cache_read = getattr(response.usage, 'cache_read_input_tokens', 0)
|
|
556
|
+
cache_write = getattr(response.usage, 'cache_creation_input_tokens', 0)
|
|
557
|
+
|
|
558
|
+
total_input_tokens += response.usage.input_tokens
|
|
559
|
+
total_output_tokens += response.usage.output_tokens
|
|
560
|
+
total_cache_read_tokens += cache_read
|
|
561
|
+
total_cache_write_tokens += cache_write
|
|
562
|
+
|
|
563
|
+
# Track input tokens from cached iterations (1-3)
|
|
564
|
+
if iteration <= 3:
|
|
565
|
+
cached_iterations_input += response.usage.input_tokens
|
|
566
|
+
|
|
567
|
+
# Calculate user message tokens in iteration 1
|
|
568
|
+
# Iteration 1: input = user_message + system_prompt + tools
|
|
569
|
+
# cache_write = system_prompt + tools (if caching)
|
|
570
|
+
# user_message = input - cache_write
|
|
571
|
+
if iteration == 1:
|
|
572
|
+
if cache_write > 0:
|
|
573
|
+
# Session with existing cache: input includes cache write
|
|
574
|
+
user_message_tokens = response.usage.input_tokens - cache_write
|
|
575
|
+
iteration_1_cache_write = cache_write
|
|
576
|
+
else:
|
|
577
|
+
# First ever query (no cache): all input is user message + system + tools
|
|
578
|
+
# We don't cache on first query, so total_input IS the cost
|
|
579
|
+
user_message_tokens = response.usage.input_tokens
|
|
580
|
+
logger.info(f" User message (+ system/tools if no cache): ~{user_message_tokens} tokens")
|
|
581
|
+
|
|
582
|
+
logger.info(f" FILES: Tokens: {response.usage.input_tokens} in / {response.usage.output_tokens} out")
|
|
583
|
+
if cache_read > 0 or cache_write > 0:
|
|
584
|
+
logger.info(f" 💾 Cache: {cache_read} read / {cache_write} write")
|
|
585
|
+
|
|
586
|
+
# Check stop reason
|
|
587
|
+
if response.stop_reason == "end_turn":
|
|
588
|
+
# No tools used, stream the final text
|
|
589
|
+
logger.info("Claude finished (no more tools)")
|
|
590
|
+
|
|
591
|
+
# Update status to "Preparing answer..." if this is iteration 5 or final iteration
|
|
592
|
+
if iteration >= 4:
|
|
593
|
+
status.set_preparing_answer()
|
|
594
|
+
yield status.get_status_message()
|
|
595
|
+
|
|
596
|
+
for block in response.content:
|
|
597
|
+
if block.type == "text":
|
|
598
|
+
logger.info(f"Streaming response ({len(block.text)} chars)")
|
|
599
|
+
# Yield text chunks
|
|
600
|
+
for char in block.text:
|
|
601
|
+
yield {"type": "text", "content": char}
|
|
602
|
+
|
|
603
|
+
# Calculate token savings
|
|
604
|
+
# User input = all input tokens from iterations 1-3 minus overhead
|
|
605
|
+
# Overhead = system prompt + conversation history
|
|
606
|
+
# Example: 3349 input - 300 prompt - 1050 history = 1999 tokens
|
|
607
|
+
user_input_only = max(0, cached_iterations_input - PROMPT_OVERHEAD - conversation_history_tokens)
|
|
608
|
+
traditional_equiv, new_savings_pct = self._calculate_savings(user_input_only, len(files_accessed))
|
|
609
|
+
|
|
610
|
+
# If no files accessed, keep previous savings percentage (don't reset to 0%)
|
|
611
|
+
if len(files_accessed) == 0 and hasattr(self, 'last_savings_pct'):
|
|
612
|
+
savings_pct = self.last_savings_pct
|
|
613
|
+
else:
|
|
614
|
+
savings_pct = new_savings_pct
|
|
615
|
+
self.last_savings_pct = new_savings_pct # Save for next time
|
|
616
|
+
|
|
617
|
+
# Yield final token count
|
|
618
|
+
total_tokens = total_input_tokens + total_output_tokens
|
|
619
|
+
logger.info(f"\nTOTAL: {total_input_tokens} input, {total_output_tokens} output, {total_cache_read_tokens} cache read, {total_cache_write_tokens} cache write (total {total_tokens})")
|
|
620
|
+
logger.info(f"FILES: {len(files_accessed)} accessed, traditional: {traditional_equiv} tokens, savings: {savings_pct:.1f}%")
|
|
621
|
+
logger.info("=" * 80)
|
|
622
|
+
|
|
623
|
+
# Mark query as complete
|
|
624
|
+
status.set_complete()
|
|
625
|
+
yield status.get_status_message()
|
|
626
|
+
|
|
627
|
+
# End status tracking
|
|
628
|
+
status.end_query()
|
|
629
|
+
|
|
630
|
+
yield {
|
|
631
|
+
"type": "tokens",
|
|
632
|
+
"input": total_input_tokens,
|
|
633
|
+
"output": total_output_tokens,
|
|
634
|
+
"total": total_tokens,
|
|
635
|
+
"cache_read": total_cache_read_tokens,
|
|
636
|
+
"cache_write": total_cache_write_tokens,
|
|
637
|
+
"user_input_only": user_input_only,
|
|
638
|
+
"files_accessed": len(files_accessed),
|
|
639
|
+
"traditional_equivalent": traditional_equiv,
|
|
640
|
+
"savings_percentage": savings_pct
|
|
641
|
+
}
|
|
642
|
+
return
|
|
643
|
+
|
|
644
|
+
elif response.stop_reason == "tool_use":
|
|
645
|
+
# Block tools after iteration 3 (cache is full)
|
|
646
|
+
if iteration > 3:
|
|
647
|
+
logger.error(f"BLOCKED: BLOCKED: Claude tried to call {sum(1 for b in response.content if b.type == 'tool_use')} tools in iteration {iteration}!")
|
|
648
|
+
logger.error(" Tools are ONLY allowed in iterations 1-3 (cached). Forcing answer with cached data.")
|
|
649
|
+
|
|
650
|
+
# Skip appending assistant message with tool_use to avoid API error
|
|
651
|
+
# Inject user message to force answer
|
|
652
|
+
messages.append({
|
|
653
|
+
"role": "user",
|
|
654
|
+
"content": "BLOCKED: TOOL CALLS BLOCKED! You are in iteration 4+. Tools are ONLY allowed in iterations 1-3. You have ALL the data from cached iterations. Provide your complete answer NOW. DO NOT call any more tools."
|
|
655
|
+
})
|
|
656
|
+
# Loop back to get answer
|
|
657
|
+
continue
|
|
658
|
+
|
|
659
|
+
# Claude wants to use tools
|
|
660
|
+
logger.info(" Claude is using tools...")
|
|
661
|
+
|
|
662
|
+
# Add assistant's response to messages
|
|
663
|
+
messages.append({
|
|
664
|
+
"role": "assistant",
|
|
665
|
+
"content": response.content
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
# Count total tools first
|
|
669
|
+
total_tools = sum(1 for block in response.content if block.type == "tool_use")
|
|
670
|
+
|
|
671
|
+
# Execute all tool calls (don't stream this part)
|
|
672
|
+
tool_results = []
|
|
673
|
+
tool_count = 0
|
|
674
|
+
first_tool_name = None
|
|
675
|
+
for block in response.content:
|
|
676
|
+
if block.type == "tool_use":
|
|
677
|
+
tool_count += 1
|
|
678
|
+
logger.info(f"\n Tool Tool #{tool_count}: {block.name}")
|
|
679
|
+
logger.info(f" Input: {block.input}")
|
|
680
|
+
|
|
681
|
+
# Capture first tool name for status
|
|
682
|
+
if tool_count == 1:
|
|
683
|
+
first_tool_name = block.name
|
|
684
|
+
# Update status and yield it
|
|
685
|
+
status.set_tool_status(first_tool_name, total_tools)
|
|
686
|
+
yield status.get_status_message()
|
|
687
|
+
|
|
688
|
+
# Route to correct tool handler
|
|
689
|
+
file_tools = ["read_file", "list_directory", "search_files"]
|
|
690
|
+
graph_tools = ["get_callers", "get_callees", "get_call_chain", "execute_sql",
|
|
691
|
+
"get_file_by_pattern", "get_file_info", "list_indexed_files",
|
|
692
|
+
"search_symbols", "get_symbol_definition", "get_symbols_from_file"]
|
|
693
|
+
|
|
694
|
+
if block.name in file_tools:
|
|
695
|
+
logger.info(f" Type: FILE SYSTEM TOOL")
|
|
696
|
+
result = execute_file_tool(block.name, block.input)
|
|
697
|
+
elif block.name in graph_tools:
|
|
698
|
+
logger.info(f" Type: GRAPH TOOL ")
|
|
699
|
+
result = execute_graph_tool(block.name, block.input)
|
|
700
|
+
else:
|
|
701
|
+
logger.warning(f" Type: UNKNOWN TOOL!")
|
|
702
|
+
result = {"error": f"Unknown tool: {block.name}"}
|
|
703
|
+
|
|
704
|
+
# Track files accessed for savings calculation
|
|
705
|
+
if block.name in ["read_file", "get_symbols_from_file", "get_file_info"]:
|
|
706
|
+
file_path = block.input.get("file_path")
|
|
707
|
+
if file_path:
|
|
708
|
+
files_accessed.add(file_path)
|
|
709
|
+
|
|
710
|
+
# Log result summary
|
|
711
|
+
if isinstance(result, dict):
|
|
712
|
+
if "error" in result:
|
|
713
|
+
logger.error(f" Error: {result['error']}")
|
|
714
|
+
elif "count" in result:
|
|
715
|
+
logger.info(f" Returned {result['count']} results")
|
|
716
|
+
else:
|
|
717
|
+
logger.info(f" Success (keys: {list(result.keys())})")
|
|
718
|
+
|
|
719
|
+
# Add tool result (no truncation - iterations 1-3 are cached)
|
|
720
|
+
tool_results.append({
|
|
721
|
+
"type": "tool_result",
|
|
722
|
+
"tool_use_id": block.id,
|
|
723
|
+
"content": str(result)
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
logger.info(f"\n Executed {tool_count} tool(s), sending results back to Claude...")
|
|
727
|
+
|
|
728
|
+
# Send tool results back to Claude
|
|
729
|
+
# Cache tool results if under breakpoint limit (max 4 total: system + 3 messages)
|
|
730
|
+
if tool_results and cache_breakpoints_used < max_cache_breakpoints:
|
|
731
|
+
tool_results[-1]["cache_control"] = {"type": "ephemeral"}
|
|
732
|
+
cache_breakpoints_used += 1
|
|
733
|
+
logger.info(f"CACHE: Cache breakpoint set on last tool result (iteration {iteration}, breakpoint {cache_breakpoints_used}/{max_cache_breakpoints})")
|
|
734
|
+
elif tool_results and cache_breakpoints_used >= max_cache_breakpoints:
|
|
735
|
+
logger.info(f"WARN: Skipping cache (already at {cache_breakpoints_used}/{max_cache_breakpoints} breakpoints) - rely on parallel tools to finish quickly!")
|
|
736
|
+
|
|
737
|
+
messages.append({
|
|
738
|
+
"role": "user",
|
|
739
|
+
"content": tool_results
|
|
740
|
+
})
|
|
741
|
+
|
|
742
|
+
else:
|
|
743
|
+
yield {"type": "text", "content": f"Unexpected stop reason: {response.stop_reason}"}
|
|
744
|
+
return
|
|
745
|
+
|
|
746
|
+
def stream_message(self, message):
|
|
747
|
+
"""Stream message to Claude and yield text chunks"""
|
|
748
|
+
with self.client.messages.stream(
|
|
749
|
+
model=self.model,
|
|
750
|
+
max_tokens=16000,
|
|
751
|
+
messages=[{"role": "user", "content": message}]
|
|
752
|
+
) as stream:
|
|
753
|
+
for text in stream.text_stream:
|
|
754
|
+
yield text
|