bharatcode 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.
- bharatcode/__init__.py +13 -0
- bharatcode/agent.py +2088 -0
- bharatcode/commands.py +1072 -0
- bharatcode/config.py +93 -0
- bharatcode/coordinator.py +670 -0
- bharatcode/cost.py +77 -0
- bharatcode/diff.py +113 -0
- bharatcode/hooks.py +75 -0
- bharatcode/index.py +155 -0
- bharatcode/main.py +846 -0
- bharatcode/memory.py +286 -0
- bharatcode/permissions.py +99 -0
- bharatcode/project.py +179 -0
- bharatcode/session_storage.py +108 -0
- bharatcode/skills.py +1746 -0
- bharatcode/subagent.py +363 -0
- bharatcode/tools.py +1021 -0
- bharatcode/ui.py +72 -0
- bharatcode-0.1.0.dist-info/METADATA +150 -0
- bharatcode-0.1.0.dist-info/RECORD +23 -0
- bharatcode-0.1.0.dist-info/WHEEL +5 -0
- bharatcode-0.1.0.dist-info/entry_points.txt +3 -0
- bharatcode-0.1.0.dist-info/top_level.txt +1 -0
bharatcode/agent.py
ADDED
|
@@ -0,0 +1,2088 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core agent loop — streams tokens from DeepSeek, executes tools with permission
|
|
3
|
+
checks, feeds results back. Inspired by Claude Code's agent loop architecture.
|
|
4
|
+
|
|
5
|
+
KEY DESIGN: For large files, the model outputs content directly in its text
|
|
6
|
+
response using <<<FILE:path>>> markers. The agent auto-writes these files.
|
|
7
|
+
This bypasses the JSON function-call size limit entirely.
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
from contextlib import nullcontext
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from openai import OpenAI
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.live import Live
|
|
17
|
+
from rich.text import Text
|
|
18
|
+
from rich.markdown import Markdown
|
|
19
|
+
from rich.panel import Panel
|
|
20
|
+
|
|
21
|
+
from .tools import TOOLS, _TOOLS_NO_SPAWN, COORDINATOR_TOOLS, COORDINATOR_ONLY_TOOLS, execute_tool
|
|
22
|
+
from .hooks import hooks, ToolCall
|
|
23
|
+
from .config import get_api_key, load_config, load_project_instructions
|
|
24
|
+
from .permissions import needs_approval, ask_permission
|
|
25
|
+
from .diff import show_file_diff
|
|
26
|
+
from .cost import session_cost
|
|
27
|
+
from .project import project_context_string
|
|
28
|
+
from .memory import memories_to_context
|
|
29
|
+
|
|
30
|
+
console = Console()
|
|
31
|
+
|
|
32
|
+
# Marker pattern: <<<FILE:path/to/file.html>>> ... <<<END_FILE>>>
|
|
33
|
+
FILE_MARKER_RE = re.compile(
|
|
34
|
+
r'<<<FILE:([^>]+)>>>(.*?)<<<END_FILE>>>',
|
|
35
|
+
re.DOTALL
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
SYSTEM_PROMPT = r"""You are Sylithe Code — an autonomous AI coding agent built for Indian developers. You think step-by-step, execute tasks completely, write production-quality code, and always finish what you start.
|
|
39
|
+
|
|
40
|
+
## RULE 1 — NEVER USE TOOLS UNSOLICITED
|
|
41
|
+
Only call a tool when the task genuinely requires it. Do NOT explore the filesystem, read files, or run commands just to "get context" unless asked.
|
|
42
|
+
- User says "hi" → say hi back. No tools.
|
|
43
|
+
- User shares their name → remember it conversationally. No tools.
|
|
44
|
+
- User asks a factual question you can answer → answer it. No tools.
|
|
45
|
+
- User references something from this session ("you remember...", "that file you created", "the game we built") → look at the conversation history above. Do NOT search the filesystem. If you can't find it in history, ask the user where it is.
|
|
46
|
+
- User asks you to build/fix/read something → use tools as needed. For coding tasks
|
|
47
|
+
on EXISTING code, gathering context first (grep + targeted reads of the files you
|
|
48
|
+
will touch) is REQUIRED, not "unsolicited" — never edit code you have not seen.
|
|
49
|
+
|
|
50
|
+
## RULE 2 — USE MEMORY BEFORE READING FILES
|
|
51
|
+
Persistent memories are injected below. Read them before reaching for tools.
|
|
52
|
+
- File already described in memory → you know its contents, skip read_file.
|
|
53
|
+
- Project stack already known → skip re-discovering it.
|
|
54
|
+
- Key numbers/metrics already saved → use them directly.
|
|
55
|
+
|
|
56
|
+
## RULE 3 — READING AND EDITING FILES
|
|
57
|
+
read_file returns up to 2000 lines per call. The header shows the total line
|
|
58
|
+
count; if the file continues, a notice tells you the offset to read next.
|
|
59
|
+
Most files fit in one call.
|
|
60
|
+
- Never pass a line number as path (path='C:/file.py', not path='200').
|
|
61
|
+
- Use offset=/limit= for targeted ranges (e.g. after grep gave you a line number).
|
|
62
|
+
- Re-reading a file is FREE — repeat reads are served from the session cache.
|
|
63
|
+
If you are about to edit_file and are not 100% certain of the file's CURRENT
|
|
64
|
+
exact content, re-read it first.
|
|
65
|
+
- READ-BEFORE-EDIT IS ENFORCED: edit_file is mechanically BLOCKED on any file
|
|
66
|
+
you have not read this session, and BLOCKED if the file changed on disk after
|
|
67
|
+
your last read. When an edit is blocked → read_file the file, then retry.
|
|
68
|
+
(Files you wrote yourself this session count as read.)
|
|
69
|
+
|
|
70
|
+
## RULE 4 — WRITE LARGE FILES VIA MARKER (not write_file tool)
|
|
71
|
+
For files >150 lines (HTML dashboards, full rewrites, reports), output content directly in your response using this format — JSON function-call args get truncated for large content:
|
|
72
|
+
<<<FILE:C:/full/absolute/path/output.html>>>
|
|
73
|
+
<!DOCTYPE html>
|
|
74
|
+
... complete file content, every single line ...
|
|
75
|
+
</html>
|
|
76
|
+
<<<END_FILE>>>
|
|
77
|
+
The agent auto-detects this, writes it to disk, and shows a colored diff.
|
|
78
|
+
Multiple <<<FILE:>>> blocks in one response are fine.
|
|
79
|
+
Use write_file tool only for short files (<150 lines).
|
|
80
|
+
|
|
81
|
+
## RULE 5 — SAVE MEMORY AFTER EVERY TASK
|
|
82
|
+
After completing any task, call remember() with dense, specific facts.
|
|
83
|
+
What to save: file paths + line counts + what they contain, numbers/metrics extracted, project structure, user preferences learned, task outcomes.
|
|
84
|
+
Good: remember("Created C:/analysis/dashboard.html — 800 lines, Chart.js dark-theme, 7 charts for 3 Sylithe forest sites. Site1=5.84ha/335tCO2e, Site2=3.92ha/254tCO2e, Site3=16.06ha/1097tCO2e", tag="file")
|
|
85
|
+
Bad: remember("worked on dashboard")
|
|
86
|
+
|
|
87
|
+
## RULE 6 — COMPLETE TASKS FULLY
|
|
88
|
+
Never stop halfway. If you start writing a file, write the whole file. If you start a fix, fix it completely. Run tests if they exist. Summarize what changed at the end.
|
|
89
|
+
|
|
90
|
+
## RULE 7 — SERVERS RUN IN THE BACKGROUND, NEVER IN FOREGROUND BASH
|
|
91
|
+
NEVER run a long-lived process (npm run dev, python app.py, flask run, uvicorn,
|
|
92
|
+
vite, nodemon, webpack --watch) in a normal bash call — it never exits, so the
|
|
93
|
+
call hangs forever.
|
|
94
|
+
|
|
95
|
+
To VERIFY something actually runs (do this after building an app — never just
|
|
96
|
+
claim it works):
|
|
97
|
+
1. bash(command="cd backend && python app.py", run_in_background=true)
|
|
98
|
+
→ returns a process_id like 'proc-1' immediately
|
|
99
|
+
2. process_output(process_id="proc-1", wait_seconds=4)
|
|
100
|
+
→ read the boot logs; a traceback here means FIX IT before going further
|
|
101
|
+
3. web_fetch("http://localhost:5000/api/health")
|
|
102
|
+
→ prove the endpoint actually responds
|
|
103
|
+
4. process_kill(process_id="proc-1")
|
|
104
|
+
→ ALWAYS kill every process you started once verification is done
|
|
105
|
+
|
|
106
|
+
When the USER wants the server running for themselves (not verification):
|
|
107
|
+
print the exact commands to run in their own terminals, with ports. Your
|
|
108
|
+
background processes die when the session ends — they are for verification only.
|
|
109
|
+
|
|
110
|
+
## RULE 8 — GREP BEFORE READ
|
|
111
|
+
Never cold-read a whole file just to find something inside it. Grep first.
|
|
112
|
+
- Need a function? → grep "def function_name" **/*.py — tells you file + line
|
|
113
|
+
- Need a class? → grep "class ClassName"
|
|
114
|
+
- Need where something is imported? → grep "import module_name"
|
|
115
|
+
Then read_file with offset= and limit= to load only the relevant section.
|
|
116
|
+
The Project Index below already lists every file and its top symbols — check it
|
|
117
|
+
before reaching for list_dir or read_file just to discover what exists.
|
|
118
|
+
|
|
119
|
+
## RULE 9 — THE SURGICAL BUG-FIX PROTOCOL
|
|
120
|
+
When fixing a bug or editing existing code, follow this exact sequence every time.
|
|
121
|
+
Skipping steps is what causes you to create helper scripts, burn iterations, and break things.
|
|
122
|
+
|
|
123
|
+
### STEP 1 — READ THE ERROR FULLY BEFORE TOUCHING ANY FILE
|
|
124
|
+
Copy the exact error message and categorize it:
|
|
125
|
+
- `UnicodeDecodeError` / `charmap` → encoding issue in file read or subprocess
|
|
126
|
+
- `ModuleNotFoundError` / `ImportError` → wrong import path or missing package
|
|
127
|
+
- `AttributeError` / `NameError` → function/variable doesn't exist where expected
|
|
128
|
+
- `SyntaxError` → broken code was written, check the file that was last edited
|
|
129
|
+
- `KeyError` / `TypeError` → wrong data shape, check what the function receives
|
|
130
|
+
- `not found` / `no such file` → path is wrong, check how the path is constructed
|
|
131
|
+
Categorizing first tells you WHERE to look before you look anywhere.
|
|
132
|
+
|
|
133
|
+
### STEP 2 — LOCATE WITH GREP, NOT WITH READ
|
|
134
|
+
Never open a file to find something. Grep for it:
|
|
135
|
+
- Error mentions a function name → grep "def that_function"
|
|
136
|
+
- Error mentions a class → grep "class ThatClass"
|
|
137
|
+
- Error mentions a variable → grep "variable_name\s*="
|
|
138
|
+
- Error mentions a file path → grep for the path string in the codebase
|
|
139
|
+
- Error mentions an import → grep "from module import" or "import module"
|
|
140
|
+
grep gives you: exact file + exact line number. You now know where to look.
|
|
141
|
+
|
|
142
|
+
### STEP 3 — READ ONLY THE RELEVANT SECTION
|
|
143
|
+
With the line number from grep, use read_file with offset and limit:
|
|
144
|
+
- read_file(path="file.py", offset=LINE-10, limit=40)
|
|
145
|
+
This loads 40 lines centered on the problem. Do NOT read the whole file.
|
|
146
|
+
Read the whole file ONLY if you need to understand the full structure (e.g. before a large edit).
|
|
147
|
+
|
|
148
|
+
### STEP 4 — UNDERSTAND THE FIX BEFORE MAKING IT
|
|
149
|
+
Before calling edit_file, state to yourself:
|
|
150
|
+
- What is the current code doing?
|
|
151
|
+
- What should it be doing instead?
|
|
152
|
+
- Is this a one-line fix or does it affect multiple places?
|
|
153
|
+
If multiple places are affected → grep for all of them BEFORE editing any of them.
|
|
154
|
+
If you are not sure what the fix is → re-read the section or grep for related code.
|
|
155
|
+
Never edit blindly.
|
|
156
|
+
|
|
157
|
+
### STEP 5 — MAKE THE SMALLEST POSSIBLE EDIT
|
|
158
|
+
Use edit_file for surgical changes. Rules:
|
|
159
|
+
- old_string must be copied VERBATIM from the read_file output — never typed from memory
|
|
160
|
+
- Include 2-3 lines of context around the change to make old_string unique
|
|
161
|
+
- If the edit is >20 lines, ask: can this be split into smaller edits?
|
|
162
|
+
- If you are rewriting a whole function, use <<<FILE:>>> marker, not edit_file
|
|
163
|
+
|
|
164
|
+
### STEP 6 — NEVER CREATE HELPER SCRIPTS TO DO EDITS
|
|
165
|
+
If edit_file returns "old_string not found":
|
|
166
|
+
DO THIS: call read_file(offset=LINE-5, limit=20) on that exact section.
|
|
167
|
+
Copy the exact text from the output. Retry edit_file with that exact text.
|
|
168
|
+
NOT THIS: write a _fix.py helper script that does content.replace(...)
|
|
169
|
+
Helper scripts (_fix.py, _check.py, _verify.py, _rewrite.py) are a sign you gave up.
|
|
170
|
+
They leave junk in the project, hide what actually changed, and break git history.
|
|
171
|
+
One re-read + one retry is always faster and cleaner.
|
|
172
|
+
|
|
173
|
+
### STEP 7 — VERIFY WITH ONE COMMAND, NOT A NEW FILE
|
|
174
|
+
After an edit, verify with a single bash call — never write a verification script:
|
|
175
|
+
- Python syntax: bash("py -c \"import py_compile; py_compile.compile('file.py', doraise=True)\"")
|
|
176
|
+
- Function exists: grep("def function_name", path="file.py")
|
|
177
|
+
- Import works: bash("py -c \"from routes.decorators import require_auth\"")
|
|
178
|
+
If verification fails, go back to STEP 1 with the new error.
|
|
179
|
+
|
|
180
|
+
### QUICK REFERENCE — The 7-step checklist
|
|
181
|
+
1. Read error → categorize (encoding / import / logic / path / syntax)
|
|
182
|
+
2. grep for the exact function/string mentioned in the error → get file + line
|
|
183
|
+
3. read_file with offset/limit centered on that line → get 30-40 lines of context
|
|
184
|
+
4. Understand: what is it doing vs what should it do?
|
|
185
|
+
5. edit_file with old_string copied VERBATIM from step 3 output
|
|
186
|
+
6. If edit_file fails → re-read step 3, copy exact text, retry. Never write a helper script.
|
|
187
|
+
7. Verify with one bash command. Done.
|
|
188
|
+
|
|
189
|
+
## RULE 10 — NO REWRITES. EVER.
|
|
190
|
+
|
|
191
|
+
### During app building (newapp / newsite):
|
|
192
|
+
Write each file ONCE — completely and correctly the first time using <<<FILE:>>> marker.
|
|
193
|
+
After a file is written in this session, it is LOCKED. You may NOT write_file it again.
|
|
194
|
+
If a written file has a bug → edit_file the specific broken lines. That's it.
|
|
195
|
+
The ONLY exception: the file is architecturally wrong from the ground up.
|
|
196
|
+
If so: say "Rewriting [file] because [specific reason]" before doing it. Never silent rewrites.
|
|
197
|
+
THIS IS MECHANICALLY ENFORCED: the agent BLOCKS any second full write of a file
|
|
198
|
+
unless your response text contains that explicit "Rewriting <file> because <reason>"
|
|
199
|
+
declaration. A blocked write means: switch to edit_file immediately.
|
|
200
|
+
|
|
201
|
+
### When user reports an error from your built app:
|
|
202
|
+
The error tells you EXACTLY what to fix. Do not touch anything else.
|
|
203
|
+
1. Parse the error: which FILE + LINE is it pointing at? (React/Flask/Express all tell you)
|
|
204
|
+
2. grep for the exact component name / function name / route mentioned
|
|
205
|
+
3. read_file offset/limit around that line — 20 lines of context
|
|
206
|
+
4. Fix ONLY that line or function — nothing surrounding it
|
|
207
|
+
5. Do NOT rewrite the whole file because of one 5-line bug
|
|
208
|
+
|
|
209
|
+
### The rewrite death spiral (what you must never do):
|
|
210
|
+
User: "There's an error in login"
|
|
211
|
+
BAD: Write an entirely new LoginPage.jsx and auth.service.js from scratch
|
|
212
|
+
GOOD: grep "LoginPage" → read_file lines 80-120 → edit_file the broken 3 lines
|
|
213
|
+
|
|
214
|
+
### Frontend ↔ Backend connection errors — standard checklist:
|
|
215
|
+
If the frontend can't reach the backend, check in this exact order:
|
|
216
|
+
1. Is `vite.config.js` proxy pointing to the correct backend port?
|
|
217
|
+
2. Does `frontend/.env` have `VITE_API_URL=http://localhost:BACKENDPORT`?
|
|
218
|
+
3. Does `services/api.js` use `import.meta.env.VITE_API_URL` (not a hardcoded URL)?
|
|
219
|
+
4. Does the backend have CORS configured for `http://localhost:FRONTENDPORT`?
|
|
220
|
+
5. Does the backend have `GET /api/health` that returns 200?
|
|
221
|
+
Fix whichever step fails. Do not rewrite both sides from scratch.
|
|
222
|
+
|
|
223
|
+
## RULE 11 — PLAN WITH THE todo TOOL
|
|
224
|
+
For any task with 3 or more steps (app builds, multi-file fixes, refactors):
|
|
225
|
+
1. FIRST call todo with the complete plan — every step as a task, the first one in_progress.
|
|
226
|
+
2. Update it as you work: mark a task completed THE MOMENT it is done, set the next
|
|
227
|
+
one in_progress. Exactly ONE task in_progress at a time.
|
|
228
|
+
3. Never end your turn while tasks are in_progress or pending — finish them, or tell
|
|
229
|
+
the user explicitly why you stopped.
|
|
230
|
+
The current list is re-shown to you on every step — keep it accurate. This is how
|
|
231
|
+
you avoid forgetting steps and abandoning builds halfway.
|
|
232
|
+
|
|
233
|
+
## TOOL GUIDE
|
|
234
|
+
| Tool | Use for |
|
|
235
|
+
|--------------|----------------------------------------------------------------------|
|
|
236
|
+
| bash | Shell commands, git, npm/pip install, run tests, create dirs |
|
|
237
|
+
| read_file | Read any file — whole file, single call, any size |
|
|
238
|
+
| write_file | Create/overwrite files under 150 lines |
|
|
239
|
+
| edit_file | Surgical replace of 1–20 lines in existing file |
|
|
240
|
+
| glob | Find files by pattern: **/*.py, src/**/*.ts, *.html |
|
|
241
|
+
| grep | Search text/regex across files with line numbers |
|
|
242
|
+
| web_fetch | Fetch URL content — docs, Stack Overflow, GitHub, PyPI |
|
|
243
|
+
| list_dir | Explore directory — sizes, file types, structure |
|
|
244
|
+
| remember | Save facts to persistent memory (survives across sessions) |
|
|
245
|
+
| todo | Live task checklist — create at start of multi-step work, update as you go |
|
|
246
|
+
| process_output | Read logs/status of a background process (servers started via bash) |
|
|
247
|
+
| process_kill | Stop a background process you started — always clean up |
|
|
248
|
+
| spawn_agent | Spawn a specialized sub-agent for explore/code/verify/research tasks |
|
|
249
|
+
|
|
250
|
+
## SPAWN_AGENT — When and How
|
|
251
|
+
Use spawn_agent when you want a specialized agent to do isolated work:
|
|
252
|
+
- Exploring the codebase while you plan: spawn_agent(agent_type="explore", task="List all API routes in the backend and their auth requirements")
|
|
253
|
+
- Verifying your code after writing: spawn_agent(agent_type="verifier", task="Read the files I just wrote and find all bugs: [list files here]")
|
|
254
|
+
- Researching a library: spawn_agent(agent_type="researcher", task="Find the exact API for Razorpay subscription webhooks with Python example")
|
|
255
|
+
- Building a complex subtask: spawn_agent(agent_type="coder", task="Implement the backend auth endpoints in /project/backend/auth.py")
|
|
256
|
+
|
|
257
|
+
Important: write self-contained tasks — the subagent has no memory of your conversation.
|
|
258
|
+
For parallel work: call spawn_agent in separate tool calls within the same response.
|
|
259
|
+
|
|
260
|
+
## INDIAN TECH EXPERTISE
|
|
261
|
+
|
|
262
|
+
### Languages & Frameworks
|
|
263
|
+
- Python: Django, Flask, FastAPI, Celery, SQLAlchemy, Pydantic, Pytest
|
|
264
|
+
- Java: Spring Boot, Spring Security, Hibernate, Maven, Gradle, JUnit
|
|
265
|
+
- JavaScript/TypeScript: Node.js, Express, NestJS, React, Next.js, Angular, Vue, Vite
|
|
266
|
+
- Mobile: React Native, Flutter/Dart, Kotlin (Android), Swift (iOS)
|
|
267
|
+
- Data: Pandas, NumPy, Scikit-learn, TensorFlow, PyTorch, Jupyter
|
|
268
|
+
|
|
269
|
+
### Indian Payment Integrations
|
|
270
|
+
- Razorpay: Orders API, Webhooks, Subscriptions, UPI AutoPay, Route (marketplace splits)
|
|
271
|
+
- PayU: PayUmoney, LazyPay BNPL, EMI options
|
|
272
|
+
- Cashfree: Payouts API, beneficiary management, bulk transfers
|
|
273
|
+
- UPI/NPCI: UPI deep links, QR generation, VPA validation
|
|
274
|
+
- NEFT/RTGS/IMPS: Bank transfer APIs via Razorpay/Cashfree
|
|
275
|
+
- PhonePe: PG SDK, intent flow, S2S payments
|
|
276
|
+
- Paytm: PG, Paytm for Business, Soundbox integration
|
|
277
|
+
|
|
278
|
+
### Indian Compliance & Regulations
|
|
279
|
+
- DPDP Act 2023: consent management, data principal rights (access/correction/erasure), data fiduciary obligations, grievance officer requirement, data localisation, breach notification within 72 hours
|
|
280
|
+
- GST: CGST/SGST (intrastate) vs IGST (interstate) split logic, tax slabs (0/5/12/18/28%), CESS, HSN/SAC codes, GSTIN validation, e-invoicing (IRN generation), e-way bill, GSTR-1/3B filing logic
|
|
281
|
+
- RBI: PPI guidelines (closed/semi-closed/open wallets), PA/PG licensing, card data tokenisation (no raw PAN storage), 2FA mandate, NBFC regulations, KYC (Video KYC, Aadhaar OTP, CKYC)
|
|
282
|
+
- Aadhaar/UIDAI: OTP-based eKYC, masked Aadhaar display (last 4 digits only), no full Aadhaar storage, UIDAI API authentication
|
|
283
|
+
- PAN: format validation (AAAAA0000A), PAN-Aadhaar linkage check, TDS deduction logic
|
|
284
|
+
- IndiaStack: DigiLocker (Aadhaar XML, driving license, marksheets), Account Aggregator framework, ONDC protocol
|
|
285
|
+
- TDS/TCS: Section 194C/194J/194H rates, deduction logic, Form 26AS reconciliation
|
|
286
|
+
|
|
287
|
+
### Cloud & DevOps (India focus)
|
|
288
|
+
- AWS Mumbai (ap-south-1): EC2, RDS, S3, CloudFront, SES, SNS, Lambda
|
|
289
|
+
- GCP Mumbai (asia-south1): GKE, Cloud SQL, Firebase, Vertex AI
|
|
290
|
+
- Azure India: App Service, Cosmos DB, Azure OpenAI
|
|
291
|
+
- Indian CDNs: Cloudflare India PoPs, Fastly, AWS CloudFront India
|
|
292
|
+
- DevOps: Docker, Kubernetes, GitHub Actions, GitLab CI, Jenkins, Nginx, Gunicorn, PM2, Supervisor
|
|
293
|
+
- Monitoring: Sentry, Grafana, Prometheus, ELK Stack, New Relic, Datadog
|
|
294
|
+
|
|
295
|
+
### Common Indian App Patterns
|
|
296
|
+
- OTP via SMS: Msg91, Twilio India, AWS SNS, 2Factor.in; TRAI DLT registration
|
|
297
|
+
- WhatsApp Business API: Meta Cloud API, Gupshup, Interakt
|
|
298
|
+
- Email: SendGrid, Mailgun, AWS SES, SparkPost (with proper SPF/DKIM for .in domains)
|
|
299
|
+
- Maps: Google Maps India, MapmyIndia (Mappls), OSRM for routing
|
|
300
|
+
- Regional language support: Unicode handling, Devanagari/Bengali/Tamil fonts, right-to-left (Urdu)
|
|
301
|
+
- Festivals/holidays: India public holiday calendar, regional holidays by state
|
|
302
|
+
"""
|
|
303
|
+
|
|
304
|
+
TOOL_ICONS = {
|
|
305
|
+
"bash": "⚡",
|
|
306
|
+
"read_file": "📖",
|
|
307
|
+
"write_file": "✏️",
|
|
308
|
+
"edit_file": "🔧",
|
|
309
|
+
"glob": "📁",
|
|
310
|
+
"grep": "🔍",
|
|
311
|
+
"web_fetch": "🌐",
|
|
312
|
+
"list_dir": "📂",
|
|
313
|
+
"remember": "🧠",
|
|
314
|
+
"todo": "📋",
|
|
315
|
+
"process_output": "📜",
|
|
316
|
+
"process_kill": "🔪",
|
|
317
|
+
"spawn_agent": "🤖",
|
|
318
|
+
"spawn_worker": "🚀",
|
|
319
|
+
"send_message": "📨",
|
|
320
|
+
"task_stop": "🛑",
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
def _build_system(project_path: str) -> str:
|
|
324
|
+
import os
|
|
325
|
+
from .index import build_project_index
|
|
326
|
+
abs_path = os.path.abspath(project_path)
|
|
327
|
+
cwd_block = (
|
|
328
|
+
f"\n\n## CURRENT SESSION\n"
|
|
329
|
+
f"- **Working directory (authoritative)**: `{abs_path}`\n"
|
|
330
|
+
f"- All file paths you use MUST be inside `{abs_path}` unless the user "
|
|
331
|
+
f"explicitly mentions a different path.\n"
|
|
332
|
+
f"- Memories below are from PAST sessions — if a memory references a "
|
|
333
|
+
f"different project path, IGNORE that path and use `{abs_path}`."
|
|
334
|
+
)
|
|
335
|
+
parts = [SYSTEM_PROMPT]
|
|
336
|
+
parts.append(cwd_block)
|
|
337
|
+
parts.append(build_project_index(abs_path)) # Feature 5: project symbol index
|
|
338
|
+
parts.append(load_project_instructions(project_path))
|
|
339
|
+
parts.append(project_context_string(project_path))
|
|
340
|
+
parts.append(memories_to_context())
|
|
341
|
+
return "".join(parts)
|
|
342
|
+
|
|
343
|
+
# ── File cache helpers ────────────────────────────────────────────────────────
|
|
344
|
+
# file_cache maps {path: read_file output}. A reserved key holds (mtime_ns, size)
|
|
345
|
+
# per path so cache hits are validated against the file on disk — a file changed
|
|
346
|
+
# by bash (git pull, npm install, codegen) or an external editor is never served
|
|
347
|
+
# stale. Entries are capped so a long session can't grow memory without bound.
|
|
348
|
+
|
|
349
|
+
_CACHE_META_KEY = "__bc_cache_meta__"
|
|
350
|
+
_READ_LOG_KEY = "__bc_read_log__" # {normcase(abspath): mtime_ns at last read}
|
|
351
|
+
_RESERVED_KEYS = {_CACHE_META_KEY, _READ_LOG_KEY}
|
|
352
|
+
_CACHE_MAX_ENTRIES = 48 # oldest entries evicted beyond this
|
|
353
|
+
_CACHE_MAX_CONTENT = 400_000 # chars — don't hold giant files in RAM
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _cache_paths(file_cache: dict) -> list:
|
|
357
|
+
"""All real file keys in the cache (skips reserved bookkeeping keys)."""
|
|
358
|
+
return [k for k in (file_cache or {}) if k not in _RESERVED_KEYS]
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _norm_path(path: str) -> str:
|
|
362
|
+
try:
|
|
363
|
+
return os.path.normcase(os.path.abspath(str(path)))
|
|
364
|
+
except Exception:
|
|
365
|
+
return str(path)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _record_read(file_cache: dict, path: str):
|
|
369
|
+
"""Mark a file as known to the model RIGHT NOW (after a read or a write
|
|
370
|
+
the model itself made). Powers the read-before-edit enforcement."""
|
|
371
|
+
if file_cache is None or not path:
|
|
372
|
+
return
|
|
373
|
+
log = file_cache.get(_READ_LOG_KEY)
|
|
374
|
+
if not isinstance(log, dict):
|
|
375
|
+
log = {}
|
|
376
|
+
file_cache[_READ_LOG_KEY] = log
|
|
377
|
+
try:
|
|
378
|
+
log[_norm_path(path)] = os.stat(path).st_mtime_ns
|
|
379
|
+
except OSError:
|
|
380
|
+
pass
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _check_edit_allowed(file_cache: dict, path: str):
|
|
384
|
+
"""Read-before-edit enforcement (mirrors Claude Code's harness rules).
|
|
385
|
+
Returns None if the edit may proceed, else a blocking error string:
|
|
386
|
+
- the model never read the file this session → must read first
|
|
387
|
+
- the file changed on disk after the model's last read → stale knowledge,
|
|
388
|
+
must re-read before editing
|
|
389
|
+
"""
|
|
390
|
+
if not path or not os.path.exists(path) or os.path.isdir(path):
|
|
391
|
+
return None # let edit_file produce its own File-not-found error
|
|
392
|
+
log = (file_cache or {}).get(_READ_LOG_KEY)
|
|
393
|
+
stamp = log.get(_norm_path(path)) if isinstance(log, dict) else None
|
|
394
|
+
if stamp is None:
|
|
395
|
+
return (
|
|
396
|
+
f"BLOCKED: you have not read {path} in this session. Editing a file "
|
|
397
|
+
f"you have not seen causes failed edits. Call read_file on it first "
|
|
398
|
+
f"(repeat reads are free), copy old_string VERBATIM from the output, "
|
|
399
|
+
f"then retry edit_file."
|
|
400
|
+
)
|
|
401
|
+
try:
|
|
402
|
+
if os.stat(path).st_mtime_ns != stamp:
|
|
403
|
+
return (
|
|
404
|
+
f"BLOCKED: {path} changed on disk AFTER you last read it (edited "
|
|
405
|
+
f"externally or by another process). Your knowledge of it is stale. "
|
|
406
|
+
f"Call read_file on it again, then retry edit_file with the current content."
|
|
407
|
+
)
|
|
408
|
+
except OSError:
|
|
409
|
+
return None
|
|
410
|
+
return None
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _cache_put(file_cache: dict, path: str, content: str):
|
|
414
|
+
if file_cache is None or not path or content is None:
|
|
415
|
+
return
|
|
416
|
+
if len(content) > _CACHE_MAX_CONTENT:
|
|
417
|
+
return
|
|
418
|
+
meta = file_cache.get(_CACHE_META_KEY)
|
|
419
|
+
if not isinstance(meta, dict):
|
|
420
|
+
meta = {}
|
|
421
|
+
file_cache[_CACHE_META_KEY] = meta
|
|
422
|
+
try:
|
|
423
|
+
st = os.stat(path)
|
|
424
|
+
meta[path] = (st.st_mtime_ns, st.st_size)
|
|
425
|
+
except OSError:
|
|
426
|
+
meta[path] = None
|
|
427
|
+
file_cache[path] = content
|
|
428
|
+
# Evict oldest entries beyond the cap (dicts preserve insertion order)
|
|
429
|
+
paths = _cache_paths(file_cache)
|
|
430
|
+
while len(paths) > _CACHE_MAX_ENTRIES:
|
|
431
|
+
oldest = paths.pop(0)
|
|
432
|
+
file_cache.pop(oldest, None)
|
|
433
|
+
meta.pop(oldest, None)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _cache_get(file_cache: dict, path: str):
|
|
437
|
+
"""Return cached content ONLY if the file on disk is unchanged
|
|
438
|
+
(same mtime + size). Stale entries are dropped so callers re-read."""
|
|
439
|
+
if not file_cache or not path or path == _CACHE_META_KEY:
|
|
440
|
+
return None
|
|
441
|
+
content = file_cache.get(path)
|
|
442
|
+
if content is None:
|
|
443
|
+
return None
|
|
444
|
+
meta = file_cache.get(_CACHE_META_KEY)
|
|
445
|
+
stamp = meta.get(path) if isinstance(meta, dict) else None
|
|
446
|
+
try:
|
|
447
|
+
st = os.stat(path)
|
|
448
|
+
if stamp is not None and stamp == (st.st_mtime_ns, st.st_size):
|
|
449
|
+
return content
|
|
450
|
+
except OSError:
|
|
451
|
+
pass
|
|
452
|
+
# Changed on disk (or unverifiable) — invalidate, force a fresh read
|
|
453
|
+
file_cache.pop(path, None)
|
|
454
|
+
if isinstance(meta, dict):
|
|
455
|
+
meta.pop(path, None)
|
|
456
|
+
return None
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _cache_copy(file_cache: dict) -> dict:
|
|
460
|
+
"""Copy a file cache for a subagent/worker thread. The meta and read-log
|
|
461
|
+
dicts are copied too so parallel threads never mutate each other's state."""
|
|
462
|
+
if not file_cache:
|
|
463
|
+
return {}
|
|
464
|
+
copied = dict(file_cache)
|
|
465
|
+
for key in _RESERVED_KEYS:
|
|
466
|
+
inner = copied.get(key)
|
|
467
|
+
if isinstance(inner, dict):
|
|
468
|
+
copied[key] = dict(inner)
|
|
469
|
+
return copied
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _invalidate_cache(file_cache: dict, path: str):
|
|
473
|
+
"""Drop every cache entry that points at the same file on disk,
|
|
474
|
+
regardless of how the path was spelled (slashes, case, relative)."""
|
|
475
|
+
if not file_cache:
|
|
476
|
+
return
|
|
477
|
+
try:
|
|
478
|
+
target = os.path.normcase(os.path.abspath(str(path)))
|
|
479
|
+
except Exception:
|
|
480
|
+
return
|
|
481
|
+
meta = file_cache.get(_CACHE_META_KEY)
|
|
482
|
+
for k in _cache_paths(file_cache):
|
|
483
|
+
try:
|
|
484
|
+
if os.path.normcase(os.path.abspath(str(k))) == target:
|
|
485
|
+
file_cache.pop(k, None)
|
|
486
|
+
if isinstance(meta, dict):
|
|
487
|
+
meta.pop(k, None)
|
|
488
|
+
except Exception:
|
|
489
|
+
continue
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _already_written(change_log: dict, path: str) -> bool:
|
|
493
|
+
"""True if this session already wrote the file (any path spelling)."""
|
|
494
|
+
if not change_log or not path:
|
|
495
|
+
return False
|
|
496
|
+
try:
|
|
497
|
+
target = os.path.normcase(os.path.abspath(str(path)))
|
|
498
|
+
except Exception:
|
|
499
|
+
return False
|
|
500
|
+
for k, v in change_log.items():
|
|
501
|
+
try:
|
|
502
|
+
if (os.path.normcase(os.path.abspath(str(k))) == target
|
|
503
|
+
and isinstance(v, dict) and v.get("writes", 0) >= 1):
|
|
504
|
+
return True
|
|
505
|
+
except Exception:
|
|
506
|
+
continue
|
|
507
|
+
return False
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _extract_and_write_files(
|
|
511
|
+
text: str,
|
|
512
|
+
base_dir: str = None,
|
|
513
|
+
file_cache: dict = None,
|
|
514
|
+
change_log: dict = None,
|
|
515
|
+
) -> tuple[str, list[str], list[str]]:
|
|
516
|
+
"""
|
|
517
|
+
Find <<<FILE:path>>> ... <<<END_FILE>>> blocks in the response text,
|
|
518
|
+
write each to disk, return (cleaned text, written paths, blocked paths).
|
|
519
|
+
If base_dir is given, relative paths are resolved inside it (so files
|
|
520
|
+
for a newsite/newapp project always land in the project folder).
|
|
521
|
+
Invalidates file_cache and records into change_log so later edit_file
|
|
522
|
+
calls never act on stale content.
|
|
523
|
+
|
|
524
|
+
RULE 10 ENFORCEMENT: a file already written this session may not be fully
|
|
525
|
+
rewritten unless the response text explicitly declares "Rewriting <file>
|
|
526
|
+
because <reason>". Blocked rewrites are reported back so the model can
|
|
527
|
+
switch to surgical edit_file fixes instead of the rewrite death spiral.
|
|
528
|
+
"""
|
|
529
|
+
written = []
|
|
530
|
+
blocked = []
|
|
531
|
+
_base = Path(base_dir).resolve() if base_dir else None
|
|
532
|
+
_rewrite_declared = "rewrit" in text.lower()
|
|
533
|
+
|
|
534
|
+
def _write(m):
|
|
535
|
+
path = m.group(1).strip()
|
|
536
|
+
content = m.group(2)
|
|
537
|
+
if content.startswith("\n"):
|
|
538
|
+
content = content[1:]
|
|
539
|
+
p = Path(path)
|
|
540
|
+
# Anchor relative paths to base_dir when given
|
|
541
|
+
if _base and not p.is_absolute():
|
|
542
|
+
p = _base / p
|
|
543
|
+
|
|
544
|
+
# RULE 10: block silent full rewrites of files written this session
|
|
545
|
+
if (not _rewrite_declared and p.exists()
|
|
546
|
+
and _already_written(change_log, str(p))):
|
|
547
|
+
blocked.append(str(p))
|
|
548
|
+
try:
|
|
549
|
+
console.print(
|
|
550
|
+
f" ⛔ [yellow]Rewrite blocked[/yellow] [cyan]{p}[/cyan] "
|
|
551
|
+
f"[dim](already written this session — RULE 10)[/dim]"
|
|
552
|
+
)
|
|
553
|
+
except UnicodeEncodeError:
|
|
554
|
+
pass
|
|
555
|
+
return (
|
|
556
|
+
f"[BLOCKED rewrite of {p} — this file was already written this session "
|
|
557
|
+
f"(RULE 10). Fix bugs with edit_file on the specific lines. If a ground-up "
|
|
558
|
+
f"rewrite is truly required, state 'Rewriting {p.name} because <reason>' "
|
|
559
|
+
f"in your response text and output the file block again.]"
|
|
560
|
+
)
|
|
561
|
+
try:
|
|
562
|
+
old = p.read_text(encoding="utf-8", errors="replace") if p.exists() else ""
|
|
563
|
+
except Exception:
|
|
564
|
+
old = ""
|
|
565
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
566
|
+
p.write_text(content, encoding="utf-8")
|
|
567
|
+
lines = len(content.splitlines())
|
|
568
|
+
try:
|
|
569
|
+
console.print(f" ✏️ [green]Written[/green] [cyan]{p}[/cyan] [dim]({lines} lines)[/dim]")
|
|
570
|
+
except UnicodeEncodeError:
|
|
571
|
+
console.print(f" [green]Written[/green] {p.name} [dim]({lines} lines)[/dim]")
|
|
572
|
+
if old and old != content:
|
|
573
|
+
show_file_diff(str(p), old, content)
|
|
574
|
+
written.append(str(p))
|
|
575
|
+
|
|
576
|
+
# Keep session state in sync — a marker write IS a write
|
|
577
|
+
_invalidate_cache(file_cache, str(p))
|
|
578
|
+
_record_read(file_cache, str(p)) # model wrote it → knows its content
|
|
579
|
+
if change_log is not None:
|
|
580
|
+
entry = change_log.get(str(p), {"writes": 0, "edits": 0})
|
|
581
|
+
entry["writes"] += 1
|
|
582
|
+
change_log[str(p)] = entry
|
|
583
|
+
|
|
584
|
+
note = f"[File written: {p}]"
|
|
585
|
+
_syn_err = _syntax_check(str(p), content)
|
|
586
|
+
if _syn_err:
|
|
587
|
+
note = f"[File written: {p} — ⚠ {_syn_err} — fix with edit_file]"
|
|
588
|
+
try:
|
|
589
|
+
console.print(f" [bold red]⚠ Syntax error:[/bold red] [dim]{_syn_err}[/dim]")
|
|
590
|
+
except UnicodeEncodeError:
|
|
591
|
+
pass
|
|
592
|
+
return note
|
|
593
|
+
|
|
594
|
+
cleaned = FILE_MARKER_RE.sub(_write, text)
|
|
595
|
+
return cleaned, written, blocked
|
|
596
|
+
|
|
597
|
+
def _show_tool_start(name: str, args: dict):
|
|
598
|
+
icon = TOOL_ICONS.get(name, "🔧")
|
|
599
|
+
try:
|
|
600
|
+
if name == "spawn_agent":
|
|
601
|
+
atype = args.get("agent_type", "general")
|
|
602
|
+
task_pr = str(args.get("task", ""))[:60].replace("\n", "↵")
|
|
603
|
+
console.print(f" {icon} [dim]{name}[/dim] [cyan]{atype}[/cyan] [dim]{task_pr}[/dim]")
|
|
604
|
+
return
|
|
605
|
+
if name == "spawn_worker":
|
|
606
|
+
atype = args.get("agent_type", "general")
|
|
607
|
+
desc = args.get("description", "") or str(args.get("task", ""))[:50]
|
|
608
|
+
console.print(
|
|
609
|
+
f" {icon} [dim]{name}[/dim] [cyan]{atype}[/cyan] [dim]{desc}[/dim] "
|
|
610
|
+
f"[dim italic]→ background[/dim italic]"
|
|
611
|
+
)
|
|
612
|
+
return
|
|
613
|
+
if name == "send_message":
|
|
614
|
+
wid = args.get("worker_id", "?")
|
|
615
|
+
msg = str(args.get("message", ""))[:55].replace("\n", "↵")
|
|
616
|
+
console.print(f" {icon} [dim]{name}[/dim] [cyan]{wid}[/cyan] [dim]{msg}[/dim]")
|
|
617
|
+
return
|
|
618
|
+
if name == "task_stop":
|
|
619
|
+
wid = args.get("worker_id", "?")
|
|
620
|
+
console.print(f" {icon} [dim]{name}[/dim] [cyan]{wid}[/cyan]")
|
|
621
|
+
return
|
|
622
|
+
preview_val = (
|
|
623
|
+
args.get("path") or args.get("url") or args.get("command") or
|
|
624
|
+
args.get("pattern") or (list(args.values())[0] if args else "")
|
|
625
|
+
)
|
|
626
|
+
preview = str(preview_val)[:80].replace("\n", "↵")
|
|
627
|
+
console.print(f" {icon} [dim]{name}[/dim] [cyan]{preview}[/cyan]")
|
|
628
|
+
except UnicodeEncodeError:
|
|
629
|
+
console.print(f" [dim]{name}[/dim]")
|
|
630
|
+
|
|
631
|
+
def _show_tool_done(name: str, result: str, elapsed: float):
|
|
632
|
+
lines = result.count("\n") + 1
|
|
633
|
+
size = len(result)
|
|
634
|
+
summary = f"{lines} lines" if name in ("read_file", "bash") else f"{size} chars"
|
|
635
|
+
time_str = f"{elapsed*1000:.0f}ms" if elapsed < 1 else f"{elapsed:.1f}s"
|
|
636
|
+
try:
|
|
637
|
+
console.print(f" [dim green]done[/dim green] [dim]({time_str} {summary})[/dim]")
|
|
638
|
+
except UnicodeEncodeError:
|
|
639
|
+
console.print(f" done ({time_str} {summary})")
|
|
640
|
+
|
|
641
|
+
def _truncate_result(result: str, max_chars: int = 64000) -> str:
|
|
642
|
+
if len(result) <= max_chars:
|
|
643
|
+
return result
|
|
644
|
+
half = max_chars // 2
|
|
645
|
+
omitted = len(result) - max_chars
|
|
646
|
+
return f"{result[:half]}\n\n[... {omitted} chars omitted ...]\n\n{result[-half:]}"
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _build_file_restoration(to_summarise: list, file_cache: dict, budget: int = 40000) -> str:
|
|
650
|
+
"""
|
|
651
|
+
Feature 6 extension: after compaction, figure out which files were
|
|
652
|
+
actively used in the compacted messages and re-inject them so the
|
|
653
|
+
model doesn't lose working context.
|
|
654
|
+
|
|
655
|
+
Extracts file paths from tool_call arguments in the compacted messages,
|
|
656
|
+
then pulls those files from file_cache up to the token budget.
|
|
657
|
+
"""
|
|
658
|
+
import json as _json
|
|
659
|
+
# Find files that were actually used in the compacted range
|
|
660
|
+
mentioned: list[str] = []
|
|
661
|
+
seen: set[str] = set()
|
|
662
|
+
for m in to_summarise:
|
|
663
|
+
if not m.get("tool_calls"):
|
|
664
|
+
continue
|
|
665
|
+
for tc in m["tool_calls"]:
|
|
666
|
+
fn = tc.get("function", {})
|
|
667
|
+
if fn.get("name") not in ("read_file", "write_file", "edit_file"):
|
|
668
|
+
continue
|
|
669
|
+
try:
|
|
670
|
+
path = str(_json.loads(fn.get("arguments", "{}")).get("path", ""))
|
|
671
|
+
if (path and path != _CACHE_META_KEY
|
|
672
|
+
and path not in seen and path in file_cache):
|
|
673
|
+
seen.add(path)
|
|
674
|
+
mentioned.append(path)
|
|
675
|
+
except Exception:
|
|
676
|
+
pass
|
|
677
|
+
|
|
678
|
+
if not mentioned:
|
|
679
|
+
return ""
|
|
680
|
+
|
|
681
|
+
parts = []
|
|
682
|
+
used = 0
|
|
683
|
+
for path in mentioned:
|
|
684
|
+
content = file_cache.get(path, "")
|
|
685
|
+
if not content or not isinstance(content, str):
|
|
686
|
+
continue
|
|
687
|
+
lines = content.count("\n") + 1
|
|
688
|
+
if used + len(content) > budget:
|
|
689
|
+
parts.append(f"[{path}] ({lines} lines — budget exceeded, re-read if needed)")
|
|
690
|
+
continue
|
|
691
|
+
parts.append(f"[{path}] ({lines} lines)\n```\n{content}\n```")
|
|
692
|
+
used += len(content)
|
|
693
|
+
|
|
694
|
+
return "\n\n".join(parts)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _render_todos(todo_state: list):
|
|
698
|
+
"""Print the live checklist the way Claude Code renders its todo list."""
|
|
699
|
+
console.print()
|
|
700
|
+
for t in todo_state:
|
|
701
|
+
st = t.get("status")
|
|
702
|
+
try:
|
|
703
|
+
if st == "completed":
|
|
704
|
+
console.print(f" [green]✓[/green] [dim strike]{t['content']}[/dim strike]")
|
|
705
|
+
elif st == "in_progress":
|
|
706
|
+
console.print(f" [yellow]▶[/yellow] [bold]{t['content']}[/bold]")
|
|
707
|
+
else:
|
|
708
|
+
console.print(f" [dim]☐ {t['content']}[/dim]")
|
|
709
|
+
except UnicodeEncodeError:
|
|
710
|
+
console.print(f" [{st}] {t['content']}")
|
|
711
|
+
console.print()
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _apply_todo(args: dict, todo_state: list) -> str:
|
|
715
|
+
"""Validate and apply a todo tool call. Mutates todo_state in place."""
|
|
716
|
+
tasks = args.get("tasks", [])
|
|
717
|
+
if not isinstance(tasks, list):
|
|
718
|
+
return "Error: 'tasks' must be an array of {content, status} objects."
|
|
719
|
+
clean = []
|
|
720
|
+
for t in tasks:
|
|
721
|
+
if isinstance(t, dict) and t.get("content"):
|
|
722
|
+
st = t.get("status", "pending")
|
|
723
|
+
if st not in ("pending", "in_progress", "completed"):
|
|
724
|
+
st = "pending"
|
|
725
|
+
clean.append({"content": str(t["content"])[:200], "status": st})
|
|
726
|
+
todo_state[:] = clean
|
|
727
|
+
done = sum(1 for t in clean if t["status"] == "completed")
|
|
728
|
+
prog = sum(1 for t in clean if t["status"] == "in_progress")
|
|
729
|
+
return (
|
|
730
|
+
f"Task list updated: {len(clean)} tasks — {done} completed, "
|
|
731
|
+
f"{prog} in progress, {len(clean) - done - prog} pending."
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _todo_reminder(todo_state: list) -> dict:
|
|
736
|
+
"""Transient per-call reminder message (never stored in history)."""
|
|
737
|
+
done = sum(1 for t in todo_state if t.get("status") == "completed")
|
|
738
|
+
lines = []
|
|
739
|
+
for t in todo_state:
|
|
740
|
+
mark = {"completed": "[x]", "in_progress": "[>]"}.get(t.get("status"), "[ ]")
|
|
741
|
+
lines.append(f"{mark} {t.get('content', '')}")
|
|
742
|
+
return {"role": "user", "content": (
|
|
743
|
+
f"[TASK LIST REMINDER — internal, not from the user. {done}/{len(todo_state)} done. "
|
|
744
|
+
"Keep it accurate with the todo tool; finish or address every remaining item "
|
|
745
|
+
"before ending your turn.]\n" + "\n".join(lines)
|
|
746
|
+
)}
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _git_checkpoint(project_path: str, message: str, init: bool = False):
|
|
750
|
+
"""Commit all current changes as a checkpoint. Returns the short hash,
|
|
751
|
+
or None when there is nothing to commit / no repo / git unavailable."""
|
|
752
|
+
import subprocess as _sp
|
|
753
|
+
try:
|
|
754
|
+
root = Path(project_path)
|
|
755
|
+
if not (root / ".git").exists():
|
|
756
|
+
if not init:
|
|
757
|
+
return None
|
|
758
|
+
_sp.run(["git", "init", "-q"], cwd=str(root), capture_output=True, timeout=15)
|
|
759
|
+
_sp.run(["git", "add", "-A"], cwd=str(root), capture_output=True, timeout=30)
|
|
760
|
+
msg = f"bharatcode: {message[:60].strip()}" if message and message.strip() else "bharatcode checkpoint"
|
|
761
|
+
r = _sp.run(
|
|
762
|
+
["git", "-c", "user.name=Sylithe Code", "-c", "user.email=bharatcode@local",
|
|
763
|
+
"commit", "-q", "-m", msg],
|
|
764
|
+
cwd=str(root), capture_output=True, encoding="utf-8", errors="replace", timeout=30,
|
|
765
|
+
)
|
|
766
|
+
if r.returncode != 0:
|
|
767
|
+
return None # nothing to commit, or commit blocked
|
|
768
|
+
h = _sp.run(
|
|
769
|
+
["git", "rev-parse", "--short", "HEAD"],
|
|
770
|
+
cwd=str(root), capture_output=True, encoding="utf-8", errors="replace", timeout=10,
|
|
771
|
+
)
|
|
772
|
+
return (h.stdout or "").strip() or None
|
|
773
|
+
except Exception:
|
|
774
|
+
return None
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _syntax_check(path: str, content: str | None = None) -> str | None:
|
|
778
|
+
"""
|
|
779
|
+
Feature 4 — post-write syntax check for .py, .json, and .js files.
|
|
780
|
+
Returns an error description string on failure, None if clean (or untestable).
|
|
781
|
+
Reads from disk if content is not provided.
|
|
782
|
+
"""
|
|
783
|
+
ext = Path(path).suffix.lower()
|
|
784
|
+
|
|
785
|
+
if ext == '.py':
|
|
786
|
+
import ast
|
|
787
|
+
try:
|
|
788
|
+
if content is None:
|
|
789
|
+
content = Path(path).read_text(encoding='utf-8', errors='replace')
|
|
790
|
+
ast.parse(content)
|
|
791
|
+
return None
|
|
792
|
+
except SyntaxError as e:
|
|
793
|
+
return f"Python SyntaxError at line {e.lineno}: {e.msg}"
|
|
794
|
+
except Exception:
|
|
795
|
+
return None
|
|
796
|
+
|
|
797
|
+
if ext == '.json':
|
|
798
|
+
import json as _json
|
|
799
|
+
try:
|
|
800
|
+
if content is None:
|
|
801
|
+
content = Path(path).read_text(encoding='utf-8', errors='replace')
|
|
802
|
+
_json.loads(content)
|
|
803
|
+
return None
|
|
804
|
+
except _json.JSONDecodeError as e:
|
|
805
|
+
return f"Invalid JSON at line {e.lineno}: {e.msg}"
|
|
806
|
+
except Exception:
|
|
807
|
+
return None
|
|
808
|
+
|
|
809
|
+
if ext in ('.js', '.mjs', '.cjs'):
|
|
810
|
+
# node --check parses without executing. Skip silently if node missing.
|
|
811
|
+
import subprocess as _sp
|
|
812
|
+
try:
|
|
813
|
+
r = _sp.run(
|
|
814
|
+
["node", "--check", str(path)],
|
|
815
|
+
capture_output=True, encoding="utf-8", errors="replace", timeout=10,
|
|
816
|
+
)
|
|
817
|
+
if r.returncode != 0:
|
|
818
|
+
err_lines = [l for l in (r.stderr or "").strip().splitlines() if l.strip()]
|
|
819
|
+
detail = "; ".join(err_lines[-3:]) if err_lines else "syntax error"
|
|
820
|
+
return f"JavaScript SyntaxError: {detail[:300]}"
|
|
821
|
+
except (FileNotFoundError, Exception):
|
|
822
|
+
return None
|
|
823
|
+
return None
|
|
824
|
+
|
|
825
|
+
return None
|
|
826
|
+
|
|
827
|
+
# ── Context management constants ──────────────────────────────────────────────
|
|
828
|
+
|
|
829
|
+
_HISTORY_FULL_RECENT = 40 # recent messages always sent full to API
|
|
830
|
+
# DeepSeek V4 context window is 1M tokens (flash) — compaction is about cost
|
|
831
|
+
# control, not survival. Keep generous headroom: aggressive compression is what
|
|
832
|
+
# makes the model edit blind and fail tasks. /compact still works manually.
|
|
833
|
+
_COMPACT_THRESHOLD = 150000 # ~150K estimated tokens before auto-compacting
|
|
834
|
+
_COMPACT_TARGET_RATIO = 0.40 # fallback: compact oldest 40% if no safe cut found
|
|
835
|
+
_COMPACT_KEEP_RECENT = 20_000 # target tokens to keep after compaction
|
|
836
|
+
|
|
837
|
+
# Built from tool definitions — any tool declaring execution_mode="parallel" runs concurrently.
|
|
838
|
+
# Tools with no shared mutable session state (no cache writes, no RULE 10 checks) qualify.
|
|
839
|
+
_PARALLEL_TOOLS = {
|
|
840
|
+
t["function"]["name"]
|
|
841
|
+
for t in TOOLS
|
|
842
|
+
if t.get("execution_mode") == "parallel"
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
# Returned by a tool to signal the agent loop should stop after this batch.
|
|
846
|
+
# When EVERY tool in a batch returns this sentinel the outer loop exits immediately
|
|
847
|
+
# without waiting for the model to produce another response.
|
|
848
|
+
_TERMINATE_SENTINEL = "__BHARATCODE_TERMINATE__"
|
|
849
|
+
|
|
850
|
+
# ── API error classification ──────────────────────────────────────────────────
|
|
851
|
+
# Three error buckets, each routed differently:
|
|
852
|
+
# billing → stop immediately (retrying hits the same wall)
|
|
853
|
+
# overflow → compact history and retry the current turn
|
|
854
|
+
# transient → exponential backoff, up to 5 attempts
|
|
855
|
+
# unknown → one retry then surface to user
|
|
856
|
+
|
|
857
|
+
_BILLING_ERR_RE = re.compile(
|
|
858
|
+
r"billing|insufficient.?quota|out.?of.?budget|usage.?limit|available.?balance"
|
|
859
|
+
r"|free.?usage.?limit|monthly.?limit|GoUsageLimitError|FreeUsageLimitError"
|
|
860
|
+
r"|payment.?required|your.?account",
|
|
861
|
+
re.IGNORECASE,
|
|
862
|
+
)
|
|
863
|
+
_OVERFLOW_ERR_RE = re.compile(
|
|
864
|
+
r"context.?(length|window|limit|size)|maximum.?context|token.?limit"
|
|
865
|
+
r"|prompt.?too.?long|input.?too.?long|reduce.?the.?length|too.?many.?token"
|
|
866
|
+
r"|context_length_exceeded|maximum_context_length",
|
|
867
|
+
re.IGNORECASE,
|
|
868
|
+
)
|
|
869
|
+
_TRANSIENT_ERR_RE = re.compile(
|
|
870
|
+
r"overload|rate.?limit|429|5\d\d|service.?unavailable|bad.?gateway"
|
|
871
|
+
r"|gateway.?timeout|network.?error|connection|timed?.?out|stream.?ended"
|
|
872
|
+
r"|websocket|fetch.?failed|terminated|retry|internal.?server",
|
|
873
|
+
re.IGNORECASE,
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
def _classify_api_error(exc: Exception) -> str:
|
|
877
|
+
"""Return 'billing', 'overflow', 'transient', or 'unknown'."""
|
|
878
|
+
msg = str(exc)
|
|
879
|
+
if _BILLING_ERR_RE.search(msg):
|
|
880
|
+
return "billing"
|
|
881
|
+
if _OVERFLOW_ERR_RE.search(msg):
|
|
882
|
+
return "overflow"
|
|
883
|
+
if _TRANSIENT_ERR_RE.search(msg):
|
|
884
|
+
return "transient"
|
|
885
|
+
status = getattr(exc, "status_code", None) or getattr(exc, "status", None)
|
|
886
|
+
if isinstance(status, int):
|
|
887
|
+
if status == 429 or status >= 500:
|
|
888
|
+
return "transient"
|
|
889
|
+
if status == 400 and "context" in msg.lower():
|
|
890
|
+
return "overflow"
|
|
891
|
+
return "unknown"
|
|
892
|
+
|
|
893
|
+
# ── Compaction summarization prompts ─────────────────────────────────────────
|
|
894
|
+
|
|
895
|
+
SUMMARIZATION_SYSTEM_PROMPT = (
|
|
896
|
+
"You are a context summarization assistant. "
|
|
897
|
+
"Read the conversation and produce a structured summary following the exact format. "
|
|
898
|
+
"Do NOT continue the conversation or answer questions in it. ONLY output the summary."
|
|
899
|
+
)
|
|
900
|
+
|
|
901
|
+
_SUMMARIZE_PROMPT = """\
|
|
902
|
+
Create a structured context checkpoint that will let the AI continue this work.
|
|
903
|
+
|
|
904
|
+
## Goal
|
|
905
|
+
[What is the user trying to accomplish? List multiple items if needed.]
|
|
906
|
+
|
|
907
|
+
## Constraints & Preferences
|
|
908
|
+
- [Any constraints, preferences, or requirements mentioned — or "(none)"]
|
|
909
|
+
|
|
910
|
+
## Progress
|
|
911
|
+
### Done
|
|
912
|
+
- [x] [Completed tasks/changes with exact file names]
|
|
913
|
+
|
|
914
|
+
### In Progress
|
|
915
|
+
- [ ] [What was being worked on when history was cut]
|
|
916
|
+
|
|
917
|
+
### Blocked
|
|
918
|
+
- [Issues preventing progress — or "(none)"]
|
|
919
|
+
|
|
920
|
+
## Key Decisions
|
|
921
|
+
- **[Decision]**: [Brief rationale]
|
|
922
|
+
|
|
923
|
+
## Next Steps
|
|
924
|
+
1. [Ordered list of what to do next]
|
|
925
|
+
|
|
926
|
+
## Critical Context
|
|
927
|
+
- [Data, examples, error messages, exact file paths needed to continue — or "(none)"]
|
|
928
|
+
|
|
929
|
+
Keep each section concise. Preserve exact file paths, function names, and error messages."""
|
|
930
|
+
|
|
931
|
+
_UPDATE_SUMMARIZE_PROMPT = """\
|
|
932
|
+
Update the existing summary (in <previous-summary> tags) with the NEW messages above.
|
|
933
|
+
|
|
934
|
+
RULES:
|
|
935
|
+
- PRESERVE all existing information from the previous summary
|
|
936
|
+
- ADD new progress, decisions, context from the new messages
|
|
937
|
+
- Move items from "In Progress" → "Done" when completed
|
|
938
|
+
- Update "Next Steps" based on what was accomplished
|
|
939
|
+
- Remove resolved blockers; preserve exact file paths and error messages
|
|
940
|
+
|
|
941
|
+
Use the same format: Goal / Constraints & Preferences / Progress (Done/In Progress/Blocked) \
|
|
942
|
+
/ Key Decisions / Next Steps / Critical Context
|
|
943
|
+
|
|
944
|
+
Keep each section concise."""
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
_REASONING_PATTERNS = [
|
|
948
|
+
# Full-stack / connectivity
|
|
949
|
+
(r"full.?stack|frontend.{0,30}backend|backend.{0,30}frontend", "full-stack wiring"),
|
|
950
|
+
(r"\bcors\b|proxy|port\s*\d{4}|vite.config|flask.cors", "server connectivity"),
|
|
951
|
+
# Debugging
|
|
952
|
+
(r"\b(debug|not working|broken|crash|traceback|stacktrace|500 error|404 error)\b", "debugging"),
|
|
953
|
+
(r"\bwhy\s+(is|does|isn.t|doesn.t|can.t|won.t)\b", "root cause analysis"),
|
|
954
|
+
(r"\b(error|exception|issue|problem|fail)\b.{0,60}\b(fix|solve|resolve)\b", "error fixing"),
|
|
955
|
+
# Architecture / design
|
|
956
|
+
(r"\b(architecture|system design|design pattern|data model|schema)\b", "system design"),
|
|
957
|
+
(r"\bfrom scratch\b|\bbuild.{0,20}complete\b|\bentirely new\b", "complex scaffolding"),
|
|
958
|
+
(r"\bnew\s+app\b|/newapp\b|bharatcode new app", "app scaffolding"),
|
|
959
|
+
(r"\bnew\s+website\b|/newsite\b|bharatcode new website", "website scaffolding"),
|
|
960
|
+
# Optimization / algorithms
|
|
961
|
+
(r"\b(optimize|performance|bottleneck|algorithm|complexity|O\(n\))\b", "optimization"),
|
|
962
|
+
(r"\b(refactor|restructure|rewrite|overhaul)\b", "refactoring"),
|
|
963
|
+
# Integrations
|
|
964
|
+
(r"\b(razorpay|payment|webhook|oauth|jwt|authentication|authorization)\b", "auth/payment integration"),
|
|
965
|
+
(r"\b(database|migration|query|index|transaction)\b.{0,40}\b(slow|optimize|fix)\b", "DB optimization"),
|
|
966
|
+
]
|
|
967
|
+
|
|
968
|
+
_CHAT_PATTERNS = [
|
|
969
|
+
r"^(hi|hello|hey|sup)\b",
|
|
970
|
+
r"^(what is|what are|who is|when is|where is)\b",
|
|
971
|
+
r"^(explain|describe|tell me about|summarize)\b",
|
|
972
|
+
r"^(show me|list|print|display)\b",
|
|
973
|
+
r"^(how are you|how do you)\b",
|
|
974
|
+
]
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def _select_model(task: str, cfg: dict) -> tuple[str, str]:
|
|
978
|
+
"""
|
|
979
|
+
Auto-select deepseek-v4-flash (fast) vs deepseek-v4-pro (deep reasoning)
|
|
980
|
+
based on task complexity signals. Returns (model_id, reason).
|
|
981
|
+
User's /model setting is always respected as the baseline — auto-select
|
|
982
|
+
only upgrades flash→pro, never downgrades pro→flash.
|
|
983
|
+
"""
|
|
984
|
+
import re
|
|
985
|
+
from .config import MODEL_ALIASES
|
|
986
|
+
configured = cfg.get("model", "deepseek-v4-flash")
|
|
987
|
+
# Normalise any old model name that slipped through
|
|
988
|
+
configured = MODEL_ALIASES.get(configured, configured)
|
|
989
|
+
|
|
990
|
+
# User explicitly set pro — always honour it
|
|
991
|
+
if configured == "deepseek-v4-pro":
|
|
992
|
+
return configured, "user setting"
|
|
993
|
+
|
|
994
|
+
task_lower = task.lower().strip()
|
|
995
|
+
|
|
996
|
+
# Obvious simple queries → stay on flash
|
|
997
|
+
for pat in _CHAT_PATTERNS:
|
|
998
|
+
if re.match(pat, task_lower):
|
|
999
|
+
return "deepseek-v4-flash", "simple query"
|
|
1000
|
+
|
|
1001
|
+
if len(task) < 60 and not any(kw in task_lower for kw in ("fix", "error", "bug", "build", "create")):
|
|
1002
|
+
return "deepseek-v4-flash", "short request"
|
|
1003
|
+
|
|
1004
|
+
# Check for complexity signals → upgrade to pro
|
|
1005
|
+
for pat, reason in _REASONING_PATTERNS:
|
|
1006
|
+
if re.search(pat, task_lower, re.IGNORECASE):
|
|
1007
|
+
return "deepseek-v4-pro", reason
|
|
1008
|
+
|
|
1009
|
+
# Long detailed task → pro
|
|
1010
|
+
if len(task) > 400:
|
|
1011
|
+
return "deepseek-v4-pro", "complex multi-part task"
|
|
1012
|
+
|
|
1013
|
+
return "deepseek-v4-flash", "general task"
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def _estimate_tokens(messages: list) -> int:
|
|
1017
|
+
"""Rough token estimate: 1 token ≈ 4 chars (safe for English + code)."""
|
|
1018
|
+
import json as _json
|
|
1019
|
+
chars = sum(len(_json.dumps(m, ensure_ascii=False)) for m in messages)
|
|
1020
|
+
return chars // 4
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def _repair_orphaned_tool_calls(history: list) -> list:
|
|
1024
|
+
"""
|
|
1025
|
+
Fix two classes of invalid tool-message sequences that cause API 400 errors:
|
|
1026
|
+
|
|
1027
|
+
1. assistant(tool_calls) with no following tool results — compaction or
|
|
1028
|
+
interruption ate the results. Inject placeholder results.
|
|
1029
|
+
|
|
1030
|
+
2. tool result with no preceding assistant(tool_calls) — compaction ate the
|
|
1031
|
+
assistant message but left the result. Drop the stranded tool message.
|
|
1032
|
+
|
|
1033
|
+
Both errors crash the API; this function makes history safe before every call.
|
|
1034
|
+
"""
|
|
1035
|
+
repaired: list = []
|
|
1036
|
+
i = 0
|
|
1037
|
+
while i < len(history):
|
|
1038
|
+
msg = history[i]
|
|
1039
|
+
|
|
1040
|
+
if msg.get("role") == "assistant" and msg.get("tool_calls"):
|
|
1041
|
+
repaired.append(msg)
|
|
1042
|
+
# Consume all tool results that immediately follow
|
|
1043
|
+
j = i + 1
|
|
1044
|
+
found_ids: set = set()
|
|
1045
|
+
while j < len(history) and history[j].get("role") == "tool":
|
|
1046
|
+
found_ids.add(history[j].get("tool_call_id", ""))
|
|
1047
|
+
repaired.append(history[j])
|
|
1048
|
+
j += 1
|
|
1049
|
+
# Inject placeholders for missing results
|
|
1050
|
+
for tc in msg["tool_calls"]:
|
|
1051
|
+
if tc.get("id") not in found_ids:
|
|
1052
|
+
repaired.append({
|
|
1053
|
+
"role": "tool",
|
|
1054
|
+
"tool_call_id": tc["id"],
|
|
1055
|
+
"content": "[Interrupted — tool did not complete. Ask user to retry.]",
|
|
1056
|
+
})
|
|
1057
|
+
i = j
|
|
1058
|
+
|
|
1059
|
+
elif msg.get("role") == "tool":
|
|
1060
|
+
# Stranded tool result — its assistant(tool_calls) was eaten by compaction.
|
|
1061
|
+
# Drop it; sending it would cause "tool must follow tool_calls" API error.
|
|
1062
|
+
i += 1
|
|
1063
|
+
|
|
1064
|
+
else:
|
|
1065
|
+
repaired.append(msg)
|
|
1066
|
+
i += 1
|
|
1067
|
+
|
|
1068
|
+
return repaired
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
def _build_api_messages(system_content: str, history: list) -> list:
|
|
1072
|
+
"""
|
|
1073
|
+
Build the final messages list for each API call.
|
|
1074
|
+
- Repairs any orphaned tool calls first (interruption safety).
|
|
1075
|
+
- Recent messages go full; older ones are compressed to save tokens.
|
|
1076
|
+
"""
|
|
1077
|
+
system_msg = {"role": "system", "content": system_content}
|
|
1078
|
+
if not history:
|
|
1079
|
+
return [system_msg]
|
|
1080
|
+
|
|
1081
|
+
safe_history = _repair_orphaned_tool_calls(history)
|
|
1082
|
+
|
|
1083
|
+
cutoff = max(0, len(safe_history) - _HISTORY_FULL_RECENT)
|
|
1084
|
+
old, recent = safe_history[:cutoff], safe_history[cutoff:]
|
|
1085
|
+
|
|
1086
|
+
def _compress(msg: dict) -> dict:
|
|
1087
|
+
# Keep old messages useful — crushing them to a few hundred chars makes
|
|
1088
|
+
# the model lose file contents mid-task and edit blind. DeepSeek V4 has
|
|
1089
|
+
# a 1M context window; moderate trimming is enough.
|
|
1090
|
+
m = dict(msg)
|
|
1091
|
+
if m["role"] == "tool":
|
|
1092
|
+
c = m.get("content", "")
|
|
1093
|
+
if len(c) > 2000:
|
|
1094
|
+
m["content"] = c[:2000] + f" [...{len(c)-2000} chars truncated — re-read the file if you need the rest]"
|
|
1095
|
+
elif m["role"] == "assistant" and not m.get("tool_calls"):
|
|
1096
|
+
c = m.get("content", "")
|
|
1097
|
+
if len(c) > 1200:
|
|
1098
|
+
m["content"] = c[:1200] + f" [...{len(c)-1200} chars]"
|
|
1099
|
+
return m
|
|
1100
|
+
|
|
1101
|
+
return [system_msg] + [_compress(m) for m in old] + recent
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def _find_cut_point(history: list, keep_tokens: int) -> int:
|
|
1105
|
+
"""Return the index into history where we cut:
|
|
1106
|
+
history[:cut] is summarized, history[cut:] is kept.
|
|
1107
|
+
Walks backward accumulating tokens until keep_tokens is reached,
|
|
1108
|
+
then snaps to the nearest 'user' message boundary so we never cut
|
|
1109
|
+
inside an assistant(tool_calls) + tool-result block.
|
|
1110
|
+
Falls back to the old 40% ratio when no safe cut is found."""
|
|
1111
|
+
import json as _j
|
|
1112
|
+
|
|
1113
|
+
accumulated = 0
|
|
1114
|
+
last_safe_cut = None # most recent user-msg index seen while walking backward
|
|
1115
|
+
|
|
1116
|
+
for i in range(len(history) - 1, -1, -1):
|
|
1117
|
+
msg = history[i]
|
|
1118
|
+
if msg.get("role") == "user":
|
|
1119
|
+
last_safe_cut = i
|
|
1120
|
+
accumulated += len(_j.dumps(msg, ensure_ascii=False)) // 4
|
|
1121
|
+
if accumulated >= keep_tokens and last_safe_cut is not None and last_safe_cut > 0:
|
|
1122
|
+
return last_safe_cut
|
|
1123
|
+
|
|
1124
|
+
# Fallback: cut at 40% if no safe boundary found inside the budget
|
|
1125
|
+
return max(4, int(len(history) * _COMPACT_TARGET_RATIO))
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def _auto_compact(
|
|
1129
|
+
history: list,
|
|
1130
|
+
client,
|
|
1131
|
+
model: str,
|
|
1132
|
+
file_cache: dict = None,
|
|
1133
|
+
last_context_tokens: int = 0,
|
|
1134
|
+
force: bool = False,
|
|
1135
|
+
) -> bool:
|
|
1136
|
+
"""
|
|
1137
|
+
Summarise old history into a structured checkpoint when context is too large.
|
|
1138
|
+
Mutates history in-place. Returns True if compaction happened.
|
|
1139
|
+
|
|
1140
|
+
Improvements over the old version:
|
|
1141
|
+
- Uses actual API prompt-token count (last_context_tokens) when available,
|
|
1142
|
+
falls back to char/4 estimation
|
|
1143
|
+
- Smart cut point: keeps last ~20K tokens, cuts only at user-msg boundaries
|
|
1144
|
+
so we never split an assistant(tool_calls) + tool-result block
|
|
1145
|
+
- Structured summary (Goal / Progress / Key Decisions / Next Steps / Context)
|
|
1146
|
+
- Incremental updates: detects a prior compaction summary in history and calls
|
|
1147
|
+
the UPDATE prompt instead of discarding the old summary
|
|
1148
|
+
- Tracks read vs modified files and appends them to the summary
|
|
1149
|
+
"""
|
|
1150
|
+
context_tokens = last_context_tokens if last_context_tokens > 0 else _estimate_tokens(history)
|
|
1151
|
+
if not force and context_tokens < _COMPACT_THRESHOLD:
|
|
1152
|
+
return False
|
|
1153
|
+
|
|
1154
|
+
cutoff = _find_cut_point(history, _COMPACT_KEEP_RECENT)
|
|
1155
|
+
if cutoff <= 0:
|
|
1156
|
+
return False
|
|
1157
|
+
|
|
1158
|
+
to_summarise = history[:cutoff]
|
|
1159
|
+
keep = history[cutoff:]
|
|
1160
|
+
|
|
1161
|
+
# ── Detect previous compaction for incremental update ───────────────────
|
|
1162
|
+
previous_summary = None
|
|
1163
|
+
summary_start = 0
|
|
1164
|
+
for i, m in enumerate(to_summarise):
|
|
1165
|
+
if (m.get("role") == "assistant"
|
|
1166
|
+
and str(m.get("content", "")).startswith("[AUTO-COMPACTED")):
|
|
1167
|
+
previous_summary = m.get("content", "")
|
|
1168
|
+
summary_start = i + 1 # only summarize messages AFTER old compaction
|
|
1169
|
+
break
|
|
1170
|
+
|
|
1171
|
+
messages_to_summarise = to_summarise[summary_start:]
|
|
1172
|
+
if not messages_to_summarise:
|
|
1173
|
+
return False
|
|
1174
|
+
|
|
1175
|
+
# ── Track file operations ────────────────────────────────────────────────
|
|
1176
|
+
import json as _j
|
|
1177
|
+
read_files: set[str] = set()
|
|
1178
|
+
modified_files: set[str] = set()
|
|
1179
|
+
for m in messages_to_summarise:
|
|
1180
|
+
for tc in (m.get("tool_calls") or []):
|
|
1181
|
+
fn = tc.get("function", {})
|
|
1182
|
+
fname = fn.get("name", "")
|
|
1183
|
+
try:
|
|
1184
|
+
path = _j.loads(fn.get("arguments", "{}")).get("path", "")
|
|
1185
|
+
except Exception:
|
|
1186
|
+
path = ""
|
|
1187
|
+
if not path:
|
|
1188
|
+
continue
|
|
1189
|
+
if fname in ("write_file", "edit_file"):
|
|
1190
|
+
modified_files.add(path)
|
|
1191
|
+
read_files.discard(path)
|
|
1192
|
+
elif fname == "read_file" and path not in modified_files:
|
|
1193
|
+
read_files.add(path)
|
|
1194
|
+
|
|
1195
|
+
# ── Build conversation dump ──────────────────────────────────────────────
|
|
1196
|
+
lines = []
|
|
1197
|
+
for m in messages_to_summarise:
|
|
1198
|
+
role = m["role"].upper()
|
|
1199
|
+
content = m.get("content") or ""
|
|
1200
|
+
if m.get("tool_calls"):
|
|
1201
|
+
for tc in m["tool_calls"]:
|
|
1202
|
+
fn = tc.get("function", {})
|
|
1203
|
+
lines.append(f"[TOOL_CALL] {fn.get('name')}({fn.get('arguments','')[:120]})")
|
|
1204
|
+
elif content:
|
|
1205
|
+
lines.append(f"[{role}]: {content[:1200]}")
|
|
1206
|
+
dump = "\n".join(lines)
|
|
1207
|
+
|
|
1208
|
+
# Preserve the original user task verbatim across compactions
|
|
1209
|
+
first_user = next(
|
|
1210
|
+
(m for m in to_summarise
|
|
1211
|
+
if m.get("role") == "user"
|
|
1212
|
+
and not str(m.get("content", "")).startswith(
|
|
1213
|
+
("[FILE CACHE RESTORED", "[SYSTEM]", "<task-notification", "[ORIGINAL TASK"))),
|
|
1214
|
+
None,
|
|
1215
|
+
)
|
|
1216
|
+
|
|
1217
|
+
# ── Build summarization prompt ───────────────────────────────────────────
|
|
1218
|
+
prompt = f"<conversation>\n{dump}\n</conversation>\n\n"
|
|
1219
|
+
if previous_summary:
|
|
1220
|
+
prompt += f"<previous-summary>\n{previous_summary}\n</previous-summary>\n\n"
|
|
1221
|
+
prompt += _UPDATE_SUMMARIZE_PROMPT
|
|
1222
|
+
else:
|
|
1223
|
+
prompt += _SUMMARIZE_PROMPT
|
|
1224
|
+
|
|
1225
|
+
# Inject file cache paths as context hints
|
|
1226
|
+
known = _cache_paths(file_cache)[:25] if file_cache else []
|
|
1227
|
+
if known:
|
|
1228
|
+
prompt += (
|
|
1229
|
+
"\n\nFILES IN SESSION CACHE — include each in Critical Context with its purpose:\n"
|
|
1230
|
+
+ "\n".join(f" - {p} ({file_cache[p].count(chr(10)) + 1} lines)" for p in known)
|
|
1231
|
+
)
|
|
1232
|
+
|
|
1233
|
+
try:
|
|
1234
|
+
resp = client.chat.completions.create(
|
|
1235
|
+
model=model,
|
|
1236
|
+
messages=[
|
|
1237
|
+
{"role": "system", "content": SUMMARIZATION_SYSTEM_PROMPT},
|
|
1238
|
+
{"role": "user", "content": prompt},
|
|
1239
|
+
],
|
|
1240
|
+
max_tokens=2000,
|
|
1241
|
+
temperature=0.1,
|
|
1242
|
+
)
|
|
1243
|
+
summary = resp.choices[0].message.content.strip()
|
|
1244
|
+
except Exception:
|
|
1245
|
+
return False
|
|
1246
|
+
|
|
1247
|
+
# ── Append file operation list to summary ────────────────────────────────
|
|
1248
|
+
if read_files or modified_files:
|
|
1249
|
+
summary += "\n\n## Files Accessed\n"
|
|
1250
|
+
if modified_files:
|
|
1251
|
+
summary += "**Modified:** " + ", ".join(sorted(modified_files)) + "\n"
|
|
1252
|
+
if read_files:
|
|
1253
|
+
summary += "**Read:** " + ", ".join(sorted(read_files)) + "\n"
|
|
1254
|
+
|
|
1255
|
+
compact_msg = {
|
|
1256
|
+
"role": "assistant",
|
|
1257
|
+
"content": f"[AUTO-COMPACTED SESSION SUMMARY — {cutoff} messages → 1]\n{summary}",
|
|
1258
|
+
}
|
|
1259
|
+
prefix = []
|
|
1260
|
+
if first_user:
|
|
1261
|
+
prefix.append({
|
|
1262
|
+
"role": "user",
|
|
1263
|
+
"content": f"[ORIGINAL TASK — preserved through compaction]\n{first_user.get('content', '')}",
|
|
1264
|
+
})
|
|
1265
|
+
history[:] = prefix + [compact_msg] + keep
|
|
1266
|
+
|
|
1267
|
+
# Re-inject actively-used files so the model keeps full working context
|
|
1268
|
+
restored = _build_file_restoration(to_summarise, file_cache or {})
|
|
1269
|
+
if restored:
|
|
1270
|
+
history.append({
|
|
1271
|
+
"role": "user",
|
|
1272
|
+
"content": f"[FILE CACHE RESTORED AFTER COMPACTION — files you were working with]\n\n{restored}",
|
|
1273
|
+
})
|
|
1274
|
+
history.append({
|
|
1275
|
+
"role": "assistant",
|
|
1276
|
+
"content": "File contents restored. I have full working context of all files I was editing.",
|
|
1277
|
+
})
|
|
1278
|
+
|
|
1279
|
+
action = "incremental update" if previous_summary else "full summary"
|
|
1280
|
+
console.print(
|
|
1281
|
+
f"\n [dim cyan]⚡ Auto-compacted {cutoff} messages → 1 ({action})"
|
|
1282
|
+
f"{' + ' + str(len(_cache_paths(file_cache))) + ' files restored' if restored else ''}"
|
|
1283
|
+
f" ({context_tokens:,} → ~{_COMPACT_KEEP_RECENT:,} tokens)[/dim cyan]\n"
|
|
1284
|
+
)
|
|
1285
|
+
return True
|
|
1286
|
+
|
|
1287
|
+
def run_agent(
|
|
1288
|
+
task: str,
|
|
1289
|
+
project_path: str = ".",
|
|
1290
|
+
auto_approve: bool = False,
|
|
1291
|
+
on_done: callable = None,
|
|
1292
|
+
history: list = None,
|
|
1293
|
+
system_content: str = None,
|
|
1294
|
+
plan_mode: bool = False,
|
|
1295
|
+
file_cache: dict = None,
|
|
1296
|
+
allowed_tools: set = None,
|
|
1297
|
+
worker_pool = None,
|
|
1298
|
+
silent: bool = False,
|
|
1299
|
+
change_log: dict = None, # Feature 7: session-scoped change tracker
|
|
1300
|
+
todo_state: list = None, # live task checklist (mutated in place)
|
|
1301
|
+
) -> str:
|
|
1302
|
+
"""
|
|
1303
|
+
history: shared list of prior messages (no system). Mutated in-place.
|
|
1304
|
+
system_content: pre-built system prompt — pass from interactive_mode() so it
|
|
1305
|
+
is computed ONCE per session instead of once per turn.
|
|
1306
|
+
plan_mode: if True, model may only read files and propose plans.
|
|
1307
|
+
write_file, edit_file, bash are blocked.
|
|
1308
|
+
file_cache: dict {path: content} shared across all turns. read_file hits
|
|
1309
|
+
this first — so files read earlier in the session are never
|
|
1310
|
+
fetched from disk again even after auto-compaction erases them
|
|
1311
|
+
from visible history. Invalidated on write_file / edit_file.
|
|
1312
|
+
allowed_tools: when set, only tools in this set are exposed to the model.
|
|
1313
|
+
Used by subagents to restrict capabilities (explore=read-only).
|
|
1314
|
+
worker_pool: WorkerPool instance when in coordinator mode. Before each API
|
|
1315
|
+
call, pending worker <task-notification> messages are drained
|
|
1316
|
+
from the pool and injected into history so the coordinator
|
|
1317
|
+
sees completions without polling.
|
|
1318
|
+
"""
|
|
1319
|
+
import time
|
|
1320
|
+
|
|
1321
|
+
cfg = load_config()
|
|
1322
|
+
if not auto_approve:
|
|
1323
|
+
auto_approve = cfg.get("auto_approve", False)
|
|
1324
|
+
|
|
1325
|
+
client = OpenAI(
|
|
1326
|
+
api_key=get_api_key(),
|
|
1327
|
+
base_url="https://api.deepseek.com",
|
|
1328
|
+
)
|
|
1329
|
+
|
|
1330
|
+
if history is None:
|
|
1331
|
+
history = []
|
|
1332
|
+
if file_cache is None:
|
|
1333
|
+
file_cache = {}
|
|
1334
|
+
if todo_state is None:
|
|
1335
|
+
todo_state = []
|
|
1336
|
+
|
|
1337
|
+
# Baseline for auto-checkpoint: did THIS call change any files?
|
|
1338
|
+
_cl_baseline = sum(
|
|
1339
|
+
v.get("writes", 0) + v.get("edits", 0)
|
|
1340
|
+
for v in (change_log or {}).values() if isinstance(v, dict)
|
|
1341
|
+
)
|
|
1342
|
+
|
|
1343
|
+
# Build system content once if not cached
|
|
1344
|
+
if system_content is None:
|
|
1345
|
+
system_content = _build_system(project_path)
|
|
1346
|
+
|
|
1347
|
+
# Plan mode: inject read-only restriction on top of system prompt
|
|
1348
|
+
if plan_mode:
|
|
1349
|
+
system_content = system_content + (
|
|
1350
|
+
"\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
|
|
1351
|
+
"## PLAN MODE — READ ONLY\n"
|
|
1352
|
+
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
|
|
1353
|
+
"You are in PLAN MODE. You may ONLY read files, grep, glob, list_dir, and web_fetch.\n"
|
|
1354
|
+
"Do NOT call write_file, edit_file, or bash — they are DISABLED.\n"
|
|
1355
|
+
"Explore the codebase thoroughly, then present a clear, numbered implementation plan.\n"
|
|
1356
|
+
"End your plan with: **PLAN COMPLETE — type /plan to approve and execute.**"
|
|
1357
|
+
)
|
|
1358
|
+
|
|
1359
|
+
# Empty task means "notifications already in history — don't add a new user message"
|
|
1360
|
+
if task:
|
|
1361
|
+
history.append({"role": "user", "content": task})
|
|
1362
|
+
|
|
1363
|
+
total_in = total_out = 0
|
|
1364
|
+
max_iter = cfg.get("max_iterations", 100)
|
|
1365
|
+
|
|
1366
|
+
# Auto-select model based on task complexity.
|
|
1367
|
+
# Coordinator synthesis always uses pro — it must reason over multiple worker reports.
|
|
1368
|
+
if worker_pool is not None:
|
|
1369
|
+
active_model = "deepseek-v4-pro"
|
|
1370
|
+
model_reason = "coordinator synthesis"
|
|
1371
|
+
configured_model = active_model
|
|
1372
|
+
else:
|
|
1373
|
+
active_model, model_reason = _select_model(task, cfg)
|
|
1374
|
+
configured_model = cfg.get("model", "deepseek-v4-flash")
|
|
1375
|
+
|
|
1376
|
+
if not silent:
|
|
1377
|
+
from .config import model_label
|
|
1378
|
+
if active_model != configured_model:
|
|
1379
|
+
icon = "🧠" if active_model == "deepseek-v4-pro" else "⚡"
|
|
1380
|
+
console.print(
|
|
1381
|
+
f" {icon} [dim]Auto-selected [cyan]{model_label(active_model)}[/cyan] ({model_reason})[/dim]"
|
|
1382
|
+
)
|
|
1383
|
+
else:
|
|
1384
|
+
icon = "🧠" if active_model == "deepseek-v4-pro" else "⚡"
|
|
1385
|
+
console.print(f" {icon} [dim]{model_label(active_model)}[/dim]")
|
|
1386
|
+
|
|
1387
|
+
# Build tool list for this agent:
|
|
1388
|
+
# - Coordinator mode gets coordinator tools (spawn_worker/send_message/task_stop + reads)
|
|
1389
|
+
# - Subagents with allowed_tools restriction get a filtered subset
|
|
1390
|
+
# - Main agent (no restrictions): all tools including spawn_agent
|
|
1391
|
+
if worker_pool is not None:
|
|
1392
|
+
# Coordinator mode — orchestration tools only, no write/bash
|
|
1393
|
+
active_tools = COORDINATOR_TOOLS
|
|
1394
|
+
elif allowed_tools is not None:
|
|
1395
|
+
# Restricted subagent: only allowed tool names, no spawn_agent
|
|
1396
|
+
active_tools = [
|
|
1397
|
+
t for t in _TOOLS_NO_SPAWN
|
|
1398
|
+
if t["function"]["name"] in allowed_tools
|
|
1399
|
+
]
|
|
1400
|
+
else:
|
|
1401
|
+
# Main agent: all tools including spawn_agent
|
|
1402
|
+
active_tools = TOOLS
|
|
1403
|
+
|
|
1404
|
+
_last_prompt_tokens = 0 # actual API prompt-token count from the previous turn
|
|
1405
|
+
_overflow_recovery_attempted = False # prevent infinite compact → overflow → compact loops
|
|
1406
|
+
|
|
1407
|
+
for iteration in range(max_iter):
|
|
1408
|
+
# Warn once at 80% of limit so user can /compact before hitting the wall
|
|
1409
|
+
if not silent and iteration == int(max_iter * 0.8):
|
|
1410
|
+
console.print(
|
|
1411
|
+
f" [bold yellow]⚠ {iteration}/{max_iter} iterations used "
|
|
1412
|
+
f"({int(max_iter*0.8)}% of limit). "
|
|
1413
|
+
"Run /compact to free context if this task will take longer.[/bold yellow]"
|
|
1414
|
+
)
|
|
1415
|
+
|
|
1416
|
+
# ── Drain worker notifications (coordinator mode only) ───────────────
|
|
1417
|
+
# Before every API call, check if any background workers finished.
|
|
1418
|
+
# Their <task-notification> XML is injected as role=user messages so
|
|
1419
|
+
# the coordinator sees completions in its conversation naturally.
|
|
1420
|
+
if worker_pool is not None:
|
|
1421
|
+
notifications = worker_pool.drain_notifications()
|
|
1422
|
+
if notifications:
|
|
1423
|
+
history.extend(notifications)
|
|
1424
|
+
console.print(
|
|
1425
|
+
f" [dim cyan]📬 {len(notifications)} worker notification(s) ready[/dim cyan]"
|
|
1426
|
+
)
|
|
1427
|
+
|
|
1428
|
+
# Auto-compact if history is getting large (mutates history in-place)
|
|
1429
|
+
_auto_compact(history, client, active_model, file_cache=file_cache,
|
|
1430
|
+
last_context_tokens=_last_prompt_tokens)
|
|
1431
|
+
|
|
1432
|
+
# Rebuild messages — recent full, older compressed
|
|
1433
|
+
messages = _build_api_messages(system_content, history)
|
|
1434
|
+
|
|
1435
|
+
# Re-show the live task list every call (transient — never enters history)
|
|
1436
|
+
if todo_state:
|
|
1437
|
+
messages.append(_todo_reminder(todo_state))
|
|
1438
|
+
|
|
1439
|
+
content_parts: list[str] = []
|
|
1440
|
+
tool_calls_raw: dict[int, dict] = {}
|
|
1441
|
+
finish_reason = None
|
|
1442
|
+
usage_in = usage_out = 0 # accumulated across retries + continuations
|
|
1443
|
+
_round_in = _round_out = 0 # usage reported by the current stream
|
|
1444
|
+
|
|
1445
|
+
def _consume_chunk(chunk, idx_offset: int = 0):
|
|
1446
|
+
"""Parse one SSE chunk into content_parts / tool_calls_raw / usage."""
|
|
1447
|
+
nonlocal finish_reason, _round_in, _round_out
|
|
1448
|
+
choice = chunk.choices[0] if chunk.choices else None
|
|
1449
|
+
if not choice:
|
|
1450
|
+
if chunk.usage:
|
|
1451
|
+
_round_in = chunk.usage.prompt_tokens
|
|
1452
|
+
_round_out = chunk.usage.completion_tokens
|
|
1453
|
+
return
|
|
1454
|
+
delta = choice.delta
|
|
1455
|
+
finish_reason = choice.finish_reason or finish_reason
|
|
1456
|
+
if delta.content:
|
|
1457
|
+
content_parts.append(delta.content)
|
|
1458
|
+
if delta.tool_calls:
|
|
1459
|
+
for tc_delta in delta.tool_calls:
|
|
1460
|
+
idx = idx_offset + tc_delta.index
|
|
1461
|
+
if idx not in tool_calls_raw:
|
|
1462
|
+
tool_calls_raw[idx] = {"id": "", "name": "", "arguments": ""}
|
|
1463
|
+
if tc_delta.id:
|
|
1464
|
+
tool_calls_raw[idx]["id"] = tc_delta.id
|
|
1465
|
+
if tc_delta.function:
|
|
1466
|
+
if tc_delta.function.name:
|
|
1467
|
+
tool_calls_raw[idx]["name"] = tc_delta.function.name
|
|
1468
|
+
if tc_delta.function.arguments:
|
|
1469
|
+
tool_calls_raw[idx]["arguments"] += tc_delta.function.arguments
|
|
1470
|
+
if chunk.usage:
|
|
1471
|
+
_round_in = chunk.usage.prompt_tokens
|
|
1472
|
+
_round_out = chunk.usage.completion_tokens
|
|
1473
|
+
|
|
1474
|
+
# Stream loop: retries transient API failures with backoff, and when the
|
|
1475
|
+
# output is cut by max_tokens mid-text it auto-continues (up to 3 times)
|
|
1476
|
+
# so large <<<FILE:>>> blocks are never silently written half-finished.
|
|
1477
|
+
_continue_msgs: list = []
|
|
1478
|
+
_continues = 0
|
|
1479
|
+
_was_truncated = False
|
|
1480
|
+
while True:
|
|
1481
|
+
_round_in = _round_out = 0
|
|
1482
|
+
finish_reason = None
|
|
1483
|
+
_idx_offset = len(tool_calls_raw)
|
|
1484
|
+
_cp_len = len(content_parts)
|
|
1485
|
+
_tc_snapshot = {k: dict(v) for k, v in tool_calls_raw.items()}
|
|
1486
|
+
|
|
1487
|
+
_stream_err = None
|
|
1488
|
+
_err_class = None
|
|
1489
|
+
for _attempt in range(5): # up to 5 for transient; billing/overflow break early
|
|
1490
|
+
try:
|
|
1491
|
+
stream = client.chat.completions.create(
|
|
1492
|
+
model=active_model,
|
|
1493
|
+
messages=messages + _continue_msgs,
|
|
1494
|
+
tools=active_tools,
|
|
1495
|
+
tool_choice="auto",
|
|
1496
|
+
temperature=0.1,
|
|
1497
|
+
max_tokens=16384,
|
|
1498
|
+
stream=True,
|
|
1499
|
+
)
|
|
1500
|
+
if silent:
|
|
1501
|
+
# Worker thread — consume stream without Live to avoid
|
|
1502
|
+
# "Only one Live display may be active at once" across threads
|
|
1503
|
+
for chunk in stream:
|
|
1504
|
+
_consume_chunk(chunk, _idx_offset)
|
|
1505
|
+
else:
|
|
1506
|
+
with Live("", console=console, refresh_per_second=15) as live:
|
|
1507
|
+
for chunk in stream:
|
|
1508
|
+
_consume_chunk(chunk, _idx_offset)
|
|
1509
|
+
# Update live display while streaming
|
|
1510
|
+
display = "".join(content_parts)
|
|
1511
|
+
if "<<<FILE:" in display:
|
|
1512
|
+
short = re.sub(r'<<<FILE:[^>]+>>>.*', ' [dim][writing file...][/dim]', display, flags=re.DOTALL)
|
|
1513
|
+
live.update(Text.from_markup(short))
|
|
1514
|
+
elif display:
|
|
1515
|
+
live.update(Text(display, style="white"))
|
|
1516
|
+
# Clear streamed text — Panel below is the canonical display
|
|
1517
|
+
live.update("")
|
|
1518
|
+
_stream_err = None
|
|
1519
|
+
break
|
|
1520
|
+
except Exception as _api_exc:
|
|
1521
|
+
_err_class = _classify_api_error(_api_exc)
|
|
1522
|
+
_stream_err = _api_exc
|
|
1523
|
+
# Roll back partial state from the failed stream before retrying
|
|
1524
|
+
del content_parts[_cp_len:]
|
|
1525
|
+
tool_calls_raw.clear()
|
|
1526
|
+
tool_calls_raw.update(_tc_snapshot)
|
|
1527
|
+
finish_reason = None
|
|
1528
|
+
_round_in = _round_out = 0
|
|
1529
|
+
|
|
1530
|
+
if _err_class == "billing":
|
|
1531
|
+
if not silent:
|
|
1532
|
+
console.print(
|
|
1533
|
+
f"\n [bold red]✗ Billing/quota error — stopping: "
|
|
1534
|
+
f"{str(_api_exc)[:200]}[/bold red]\n"
|
|
1535
|
+
)
|
|
1536
|
+
break # retrying hits the same wall
|
|
1537
|
+
|
|
1538
|
+
if _err_class == "overflow":
|
|
1539
|
+
break # handled by overflow recovery block below
|
|
1540
|
+
|
|
1541
|
+
# transient or unknown — exponential backoff, capped at 30s
|
|
1542
|
+
if _attempt < 4:
|
|
1543
|
+
_wait = min(2 ** _attempt, 30)
|
|
1544
|
+
if not silent:
|
|
1545
|
+
console.print(
|
|
1546
|
+
f" [yellow]⚠ API error ({_err_class}): "
|
|
1547
|
+
f"{str(_api_exc)[:120]} — "
|
|
1548
|
+
f"retrying in {_wait}s ({_attempt + 2}/5)[/yellow]"
|
|
1549
|
+
)
|
|
1550
|
+
time.sleep(_wait)
|
|
1551
|
+
|
|
1552
|
+
if _stream_err is not None and _err_class != "overflow":
|
|
1553
|
+
err_text = f"API request failed: {_stream_err}"
|
|
1554
|
+
if not silent:
|
|
1555
|
+
console.print(f"\n [bold red]✗ {err_text}[/bold red]\n")
|
|
1556
|
+
history.append({"role": "assistant", "content": f"[{err_text}]"})
|
|
1557
|
+
return err_text
|
|
1558
|
+
|
|
1559
|
+
if _stream_err is not None and _err_class == "overflow":
|
|
1560
|
+
break # exit continuation loop — overflow handler below
|
|
1561
|
+
|
|
1562
|
+
usage_in += _round_in
|
|
1563
|
+
usage_out += _round_out
|
|
1564
|
+
if _round_in > 0:
|
|
1565
|
+
_last_prompt_tokens = _round_in
|
|
1566
|
+
_was_truncated = (finish_reason == "length")
|
|
1567
|
+
|
|
1568
|
+
if _was_truncated and not tool_calls_raw and _continues < 3:
|
|
1569
|
+
_continues += 1
|
|
1570
|
+
if not silent:
|
|
1571
|
+
console.print(
|
|
1572
|
+
" [dim yellow]…output hit the token limit — auto-continuing "
|
|
1573
|
+
f"({_continues}/3)…[/dim yellow]"
|
|
1574
|
+
)
|
|
1575
|
+
_continue_msgs = [
|
|
1576
|
+
{"role": "assistant", "content": "".join(content_parts)},
|
|
1577
|
+
{"role": "user", "content": (
|
|
1578
|
+
"[SYSTEM] Your previous output was CUT OFF by the token limit "
|
|
1579
|
+
"mid-response. Continue EXACTLY from the last character you wrote. "
|
|
1580
|
+
"Do NOT repeat anything, do NOT restart the file block, do NOT add "
|
|
1581
|
+
"any preamble — output only the continuation."
|
|
1582
|
+
)},
|
|
1583
|
+
]
|
|
1584
|
+
continue
|
|
1585
|
+
break
|
|
1586
|
+
|
|
1587
|
+
# ── Context overflow recovery ─────────────────────────────────────────
|
|
1588
|
+
# The model returned a context-length error. Compact history and retry
|
|
1589
|
+
# this iteration rather than giving up. The _overflow_recovery_attempted
|
|
1590
|
+
# flag prevents infinite compact → overflow → compact loops.
|
|
1591
|
+
if _stream_err is not None and _err_class == "overflow":
|
|
1592
|
+
if not _overflow_recovery_attempted:
|
|
1593
|
+
_overflow_recovery_attempted = True
|
|
1594
|
+
if not silent:
|
|
1595
|
+
console.print(
|
|
1596
|
+
"\n [bold yellow]⚠ Context overflow — compacting history "
|
|
1597
|
+
"and retrying...[/bold yellow]\n"
|
|
1598
|
+
)
|
|
1599
|
+
_auto_compact(history, client, active_model,
|
|
1600
|
+
file_cache=file_cache,
|
|
1601
|
+
last_context_tokens=_last_prompt_tokens,
|
|
1602
|
+
force=True)
|
|
1603
|
+
continue # restart the for-loop iteration with compacted history
|
|
1604
|
+
else:
|
|
1605
|
+
err_text = (
|
|
1606
|
+
"Context overflow: even after compaction the context is too large. "
|
|
1607
|
+
"Try /compact then re-send your task."
|
|
1608
|
+
)
|
|
1609
|
+
if not silent:
|
|
1610
|
+
console.print(f"\n [bold red]✗ {err_text}[/bold red]\n")
|
|
1611
|
+
return err_text
|
|
1612
|
+
|
|
1613
|
+
total_in += usage_in
|
|
1614
|
+
total_out += usage_out
|
|
1615
|
+
session_cost.add(usage_in, usage_out)
|
|
1616
|
+
session_cost.model = active_model
|
|
1617
|
+
|
|
1618
|
+
full_content = "".join(content_parts)
|
|
1619
|
+
|
|
1620
|
+
# ── Extract and write any <<<FILE:>>> blocks ─────────────────────────
|
|
1621
|
+
_blocked_rewrites: list[str] = []
|
|
1622
|
+
if "<<<FILE:" in full_content:
|
|
1623
|
+
console.print()
|
|
1624
|
+
cleaned, written, _blocked_rewrites = _extract_and_write_files(
|
|
1625
|
+
full_content, base_dir=project_path,
|
|
1626
|
+
file_cache=file_cache, change_log=change_log,
|
|
1627
|
+
)
|
|
1628
|
+
full_content = cleaned
|
|
1629
|
+
|
|
1630
|
+
# ── Display text response ────────────────────────────────────────────
|
|
1631
|
+
if full_content.strip() and not tool_calls_raw:
|
|
1632
|
+
console.print()
|
|
1633
|
+
console.print(Panel(
|
|
1634
|
+
Markdown(full_content),
|
|
1635
|
+
border_style="green",
|
|
1636
|
+
title="[bold green]Sylithe Code[/bold green]",
|
|
1637
|
+
padding=(0, 1),
|
|
1638
|
+
))
|
|
1639
|
+
|
|
1640
|
+
# Append assistant turn to history — messages is rebuilt next iteration
|
|
1641
|
+
assistant_msg: dict = {"role": "assistant", "content": full_content}
|
|
1642
|
+
if tool_calls_raw:
|
|
1643
|
+
assistant_msg["tool_calls"] = [
|
|
1644
|
+
{
|
|
1645
|
+
"id": tc["id"],
|
|
1646
|
+
"type": "function",
|
|
1647
|
+
"function": {"name": tc["name"], "arguments": tc["arguments"]},
|
|
1648
|
+
}
|
|
1649
|
+
for tc in tool_calls_raw.values()
|
|
1650
|
+
]
|
|
1651
|
+
history.append(assistant_msg)
|
|
1652
|
+
|
|
1653
|
+
# ── Blocked rewrites: bounce back for surgical fixes, don't end turn ──
|
|
1654
|
+
if _blocked_rewrites and not tool_calls_raw:
|
|
1655
|
+
history.append({"role": "user", "content": (
|
|
1656
|
+
"[SYSTEM] RULE 10: your full rewrite of "
|
|
1657
|
+
+ ", ".join(_blocked_rewrites)
|
|
1658
|
+
+ " was BLOCKED — these files were already written this session. "
|
|
1659
|
+
"Do NOT output them again in full. Instead fix the specific problem "
|
|
1660
|
+
"with edit_file (grep → read the exact lines → edit only those lines). "
|
|
1661
|
+
"Only if the file is architecturally wrong from the ground up, state "
|
|
1662
|
+
"'Rewriting <file> because <reason>' explicitly and then rewrite it."
|
|
1663
|
+
)})
|
|
1664
|
+
continue
|
|
1665
|
+
|
|
1666
|
+
# ── Done if no tool calls ────────────────────────────────────────────
|
|
1667
|
+
if not tool_calls_raw:
|
|
1668
|
+
_show_usage(total_in, total_out)
|
|
1669
|
+
# Auto-checkpoint: commit this turn's file changes (main agent only —
|
|
1670
|
+
# parallel workers committing simultaneously would race each other)
|
|
1671
|
+
if (not silent and not plan_mode and change_log is not None
|
|
1672
|
+
and cfg.get("auto_checkpoint", True)):
|
|
1673
|
+
_cl_now = sum(
|
|
1674
|
+
v.get("writes", 0) + v.get("edits", 0)
|
|
1675
|
+
for v in change_log.values() if isinstance(v, dict)
|
|
1676
|
+
)
|
|
1677
|
+
if _cl_now > _cl_baseline:
|
|
1678
|
+
_h = _git_checkpoint(project_path, task or "session changes")
|
|
1679
|
+
if _h:
|
|
1680
|
+
console.print(f" [dim]📌 git checkpoint [cyan]{_h}[/cyan][/dim]")
|
|
1681
|
+
if on_done:
|
|
1682
|
+
on_done(full_content)
|
|
1683
|
+
return full_content
|
|
1684
|
+
|
|
1685
|
+
# ── Execute tools ────────────────────────────────────────────────────
|
|
1686
|
+
if not silent:
|
|
1687
|
+
console.print()
|
|
1688
|
+
|
|
1689
|
+
# Parallel pre-pass: when the model calls multiple parallel-safe tools in
|
|
1690
|
+
# one response, fire them all in threads simultaneously. Tools in
|
|
1691
|
+
# _PARALLEL_TOOLS have no shared mutable session state so this is safe.
|
|
1692
|
+
# spawn_agent gets its own rich display; other tools share a generic one.
|
|
1693
|
+
_par_results: dict[str, str] = {}
|
|
1694
|
+
_par_list = [(tc["id"], tc) for tc in tool_calls_raw.values()
|
|
1695
|
+
if tc["name"] in _PARALLEL_TOOLS]
|
|
1696
|
+
if len(_par_list) > 1:
|
|
1697
|
+
import threading as _th
|
|
1698
|
+
from .subagent import AGENT_TYPES as _AT
|
|
1699
|
+
|
|
1700
|
+
_spawn_calls = [(cid, tc) for cid, tc in _par_list if tc["name"] == "spawn_agent"]
|
|
1701
|
+
_other_calls = [(cid, tc) for cid, tc in _par_list if tc["name"] != "spawn_agent"]
|
|
1702
|
+
|
|
1703
|
+
if not silent:
|
|
1704
|
+
if _spawn_calls:
|
|
1705
|
+
_labels = " ".join(
|
|
1706
|
+
f"[cyan]{_AT.get(json.loads(tc.get('arguments') or '{}').get('agent_type','general'), _AT['general'])['icon']} "
|
|
1707
|
+
f"{_AT.get(json.loads(tc.get('arguments') or '{}').get('agent_type','general'), _AT['general'])['label']}[/cyan]"
|
|
1708
|
+
for _, tc in _spawn_calls
|
|
1709
|
+
)
|
|
1710
|
+
console.print(
|
|
1711
|
+
f"\n [bold cyan]⚡ {len(_spawn_calls)} agents launching in parallel[/bold cyan] "
|
|
1712
|
+
f"{_labels}\n"
|
|
1713
|
+
)
|
|
1714
|
+
if _other_calls:
|
|
1715
|
+
_names = " ".join(f"[cyan]{tc['name']}[/cyan]" for _, tc in _other_calls)
|
|
1716
|
+
console.print(
|
|
1717
|
+
f"\n [bold cyan]⚡ {len(_other_calls)} tools running in parallel[/bold cyan] "
|
|
1718
|
+
f"{_names}\n"
|
|
1719
|
+
)
|
|
1720
|
+
|
|
1721
|
+
_par_lock = _th.Lock()
|
|
1722
|
+
|
|
1723
|
+
def _run_par(cid: str, tc_item: dict):
|
|
1724
|
+
_name = tc_item["name"]
|
|
1725
|
+
_args = {}
|
|
1726
|
+
try:
|
|
1727
|
+
_args = json.loads(tc_item.get("arguments") or "{}")
|
|
1728
|
+
except Exception:
|
|
1729
|
+
pass
|
|
1730
|
+
|
|
1731
|
+
if _name == "spawn_agent":
|
|
1732
|
+
_sub_type = _args.get("agent_type", "general")
|
|
1733
|
+
_sub_task = _args.get("task", "")
|
|
1734
|
+
_info = _AT.get(_sub_type, _AT["general"])
|
|
1735
|
+
if not _sub_task:
|
|
1736
|
+
with _par_lock:
|
|
1737
|
+
_par_results[cid] = "Error: spawn_agent requires a 'task' argument."
|
|
1738
|
+
return
|
|
1739
|
+
_sys = system_content + _info["system_suffix"]
|
|
1740
|
+
_t0 = time.time()
|
|
1741
|
+
try:
|
|
1742
|
+
_out = run_agent(
|
|
1743
|
+
task=_sub_task,
|
|
1744
|
+
project_path=project_path,
|
|
1745
|
+
auto_approve=True,
|
|
1746
|
+
history=[],
|
|
1747
|
+
system_content=_sys,
|
|
1748
|
+
file_cache=_cache_copy(file_cache),
|
|
1749
|
+
allowed_tools=_info["allowed_tools"],
|
|
1750
|
+
silent=True,
|
|
1751
|
+
) or ""
|
|
1752
|
+
_dur = time.time() - _t0
|
|
1753
|
+
_r = (
|
|
1754
|
+
f"[{_info['label']} Agent — {_dur:.1f}s]\n\n{_out}"
|
|
1755
|
+
if _out else
|
|
1756
|
+
f"[{_info['label']} Agent completed in {_dur:.1f}s — no output]"
|
|
1757
|
+
)
|
|
1758
|
+
except Exception as _exc:
|
|
1759
|
+
_r = f"[{_info['label']} Agent failed: {_exc}]"
|
|
1760
|
+
with _par_lock:
|
|
1761
|
+
_par_results[cid] = _r
|
|
1762
|
+
if not silent:
|
|
1763
|
+
_dur = time.time() - _t0
|
|
1764
|
+
console.print(
|
|
1765
|
+
f" [dim green]✓[/dim green] {_info['icon']} "
|
|
1766
|
+
f"[bold]{_info['label']}[/bold] [dim]{_dur:.1f}s[/dim]"
|
|
1767
|
+
)
|
|
1768
|
+
else:
|
|
1769
|
+
# General parallel tool — no session-state side effects
|
|
1770
|
+
_t0 = time.time()
|
|
1771
|
+
try:
|
|
1772
|
+
_r = execute_tool(_name, _args)
|
|
1773
|
+
except Exception as _exc:
|
|
1774
|
+
_r = f"Error: {_exc}"
|
|
1775
|
+
with _par_lock:
|
|
1776
|
+
_par_results[cid] = _r
|
|
1777
|
+
if not silent:
|
|
1778
|
+
_dur = time.time() - _t0
|
|
1779
|
+
console.print(
|
|
1780
|
+
f" [dim green]✓[/dim green] [cyan]{_name}[/cyan] "
|
|
1781
|
+
f"[dim]{_dur:.1f}s[/dim]"
|
|
1782
|
+
)
|
|
1783
|
+
|
|
1784
|
+
_par_threads = [
|
|
1785
|
+
_th.Thread(target=_run_par, args=(cid, tc_), daemon=True)
|
|
1786
|
+
for cid, tc_ in _par_list
|
|
1787
|
+
]
|
|
1788
|
+
for _t in _par_threads:
|
|
1789
|
+
_t.start()
|
|
1790
|
+
for _t in _par_threads:
|
|
1791
|
+
_t.join()
|
|
1792
|
+
|
|
1793
|
+
if not silent:
|
|
1794
|
+
console.print(f"\n [dim]All {len(_par_list)} parallel tools done.[/dim]\n")
|
|
1795
|
+
|
|
1796
|
+
_batch_exec_count = 0 # tools that actually ran this batch
|
|
1797
|
+
_batch_term_count = 0 # of those, how many returned _TERMINATE_SENTINEL
|
|
1798
|
+
|
|
1799
|
+
for tc in tool_calls_raw.values():
|
|
1800
|
+
name = tc["name"]
|
|
1801
|
+
try:
|
|
1802
|
+
args = json.loads(tc["arguments"])
|
|
1803
|
+
except Exception:
|
|
1804
|
+
# Broken/truncated JSON args — never run the tool with empty args;
|
|
1805
|
+
# tell the model exactly what happened so it can recover.
|
|
1806
|
+
_hint = (
|
|
1807
|
+
f"Error: the arguments for this {name} call were invalid JSON"
|
|
1808
|
+
+ (" because your output was truncated by the token limit" if _was_truncated else "")
|
|
1809
|
+
+ ". If you were writing a large file, do NOT use write_file — output it "
|
|
1810
|
+
"with the <<<FILE:path>>> marker in your response text instead. "
|
|
1811
|
+
"Otherwise, retry the tool call with valid JSON."
|
|
1812
|
+
)
|
|
1813
|
+
if not silent:
|
|
1814
|
+
console.print(f" [red]✗ {name}: invalid/truncated JSON arguments[/red]")
|
|
1815
|
+
history.append({"role": "tool", "tool_call_id": tc["id"], "content": _hint})
|
|
1816
|
+
continue
|
|
1817
|
+
|
|
1818
|
+
# Plan mode: block all write/execute tools
|
|
1819
|
+
if plan_mode and name in ("write_file", "edit_file", "bash"):
|
|
1820
|
+
console.print(f" [yellow]⛔ {name} blocked — plan mode is ON. Type /plan to approve.[/yellow]")
|
|
1821
|
+
history.append({"role": "tool", "tool_call_id": tc["id"],
|
|
1822
|
+
"content": f"Blocked: {name} is disabled in plan mode. Only reads allowed."})
|
|
1823
|
+
continue
|
|
1824
|
+
|
|
1825
|
+
call = ToolCall(name=name, args=args)
|
|
1826
|
+
|
|
1827
|
+
hook_result = hooks.run_pre(call)
|
|
1828
|
+
if not hook_result.proceed:
|
|
1829
|
+
result = f"Blocked by safety hook: {hook_result.message}"
|
|
1830
|
+
console.print(f" [red]blocked[/red] {name}: {hook_result.message}")
|
|
1831
|
+
history.append({"role": "tool", "tool_call_id": tc["id"], "content": result})
|
|
1832
|
+
continue
|
|
1833
|
+
|
|
1834
|
+
approved, reason = needs_approval(name, args, auto_approve)
|
|
1835
|
+
if not approved:
|
|
1836
|
+
approved = ask_permission(name, args)
|
|
1837
|
+
if not approved:
|
|
1838
|
+
history.append({"role": "tool", "tool_call_id": tc["id"], "content": "Permission denied by user."})
|
|
1839
|
+
continue
|
|
1840
|
+
|
|
1841
|
+
if not silent:
|
|
1842
|
+
_show_tool_start(name, args)
|
|
1843
|
+
t0 = time.time()
|
|
1844
|
+
|
|
1845
|
+
_spinner = console.status("", spinner="dots") if not silent else nullcontext()
|
|
1846
|
+
with _spinner:
|
|
1847
|
+
# ── Parallel pre-results: serve any tool that ran in the pre-pass ──
|
|
1848
|
+
if tc["id"] in _par_results:
|
|
1849
|
+
result = _par_results[tc["id"]]
|
|
1850
|
+
|
|
1851
|
+
# ── Coordinator tools ─────────────────────────────────────────
|
|
1852
|
+
elif name == "spawn_worker" and worker_pool is not None:
|
|
1853
|
+
wid = worker_pool.spawn(
|
|
1854
|
+
task=args.get("task", ""),
|
|
1855
|
+
agent_type=args.get("agent_type", "general"),
|
|
1856
|
+
description=args.get("description", ""),
|
|
1857
|
+
project_path=project_path,
|
|
1858
|
+
system=None, # workers build their own clean system — NOT coordinator-enhanced
|
|
1859
|
+
file_cache=file_cache,
|
|
1860
|
+
)
|
|
1861
|
+
result = (
|
|
1862
|
+
f"Worker spawned successfully.\n"
|
|
1863
|
+
f"worker_id: {wid}\n"
|
|
1864
|
+
f"agent_type: {args.get('agent_type', 'general')}\n"
|
|
1865
|
+
f"description: {args.get('description', args.get('task', '')[:60])}\n\n"
|
|
1866
|
+
f"The worker is running in the background. You will receive a "
|
|
1867
|
+
f"<task-notification> message when it completes. "
|
|
1868
|
+
f"Continue your response to the user now — do not wait."
|
|
1869
|
+
)
|
|
1870
|
+
|
|
1871
|
+
elif name == "send_message" and worker_pool is not None:
|
|
1872
|
+
result = worker_pool.send_message(
|
|
1873
|
+
args.get("worker_id", ""),
|
|
1874
|
+
args.get("message", ""),
|
|
1875
|
+
)
|
|
1876
|
+
|
|
1877
|
+
elif name == "task_stop" and worker_pool is not None:
|
|
1878
|
+
result = worker_pool.stop(args.get("worker_id", ""))
|
|
1879
|
+
|
|
1880
|
+
elif name == "read_file":
|
|
1881
|
+
cache_key = str(args.get("path", ""))
|
|
1882
|
+
# Ranged reads (offset/limit) bypass the cache entirely — the
|
|
1883
|
+
# cache only ever holds COMPLETE files, never slices.
|
|
1884
|
+
_has_range = bool(args.get("offset")) or bool(args.get("limit"))
|
|
1885
|
+
# Session-wide cache, validated against disk mtime+size so a
|
|
1886
|
+
# file changed by bash/git/external editor is never served stale
|
|
1887
|
+
_cached_content = None if _has_range else _cache_get(file_cache, cache_key)
|
|
1888
|
+
if _cached_content is not None:
|
|
1889
|
+
result = _cached_content
|
|
1890
|
+
_record_read(file_cache, cache_key)
|
|
1891
|
+
elapsed = time.time() - t0
|
|
1892
|
+
hooks.run_post(call, result)
|
|
1893
|
+
session_cost.add_tool()
|
|
1894
|
+
if not silent:
|
|
1895
|
+
lines = result.count("\n") + 1
|
|
1896
|
+
time_str = f"{elapsed*1000:.0f}ms" if elapsed < 1 else f"{elapsed:.1f}s"
|
|
1897
|
+
console.print(
|
|
1898
|
+
f" [dim green]done[/dim green] "
|
|
1899
|
+
f"[dim]({time_str} {lines} lines [cyan]cached[/cyan])[/dim]"
|
|
1900
|
+
)
|
|
1901
|
+
history.append({
|
|
1902
|
+
"role": "tool",
|
|
1903
|
+
"tool_call_id": tc["id"],
|
|
1904
|
+
"content": _truncate_result(result),
|
|
1905
|
+
})
|
|
1906
|
+
continue
|
|
1907
|
+
result = execute_tool(name, args)
|
|
1908
|
+
if cache_key and not result.startswith("Error") and not result.startswith("File not found"):
|
|
1909
|
+
# Any successful read (full or partial) unlocks editing
|
|
1910
|
+
_record_read(file_cache, cache_key)
|
|
1911
|
+
# Cache only COMPLETE file contents — partial reads would
|
|
1912
|
+
# poison later cache hits with a slice of the file
|
|
1913
|
+
if not _has_range and "[file continues" not in result:
|
|
1914
|
+
_cache_put(file_cache, cache_key, result)
|
|
1915
|
+
elif name == "write_file":
|
|
1916
|
+
from .diff import capture_write
|
|
1917
|
+
_wpath = str(args.get("path", ""))
|
|
1918
|
+
_wcontent = args.get("content", "")
|
|
1919
|
+
_wmode = args.get("mode", "w")
|
|
1920
|
+
# RULE 10: block silent full overwrites of session-written files
|
|
1921
|
+
if (_wmode != "a" and _wpath and os.path.exists(_wpath)
|
|
1922
|
+
and _already_written(change_log, _wpath)
|
|
1923
|
+
and "rewrit" not in full_content.lower()):
|
|
1924
|
+
result = (
|
|
1925
|
+
f"BLOCKED (RULE 10): {_wpath} was already written this session. "
|
|
1926
|
+
"Fix bugs with edit_file on the specific lines — do not overwrite "
|
|
1927
|
+
"the whole file. If a ground-up rewrite is truly required, state "
|
|
1928
|
+
"'Rewriting <file> because <reason>' in your response text, then retry."
|
|
1929
|
+
)
|
|
1930
|
+
if not silent:
|
|
1931
|
+
console.print(
|
|
1932
|
+
f" ⛔ [yellow]Rewrite blocked[/yellow] [cyan]{_wpath}[/cyan] "
|
|
1933
|
+
f"[dim](RULE 10)[/dim]"
|
|
1934
|
+
)
|
|
1935
|
+
history.append({"role": "tool", "tool_call_id": tc["id"], "content": result})
|
|
1936
|
+
continue
|
|
1937
|
+
try:
|
|
1938
|
+
result = capture_write(_wpath, _wcontent, _wmode)
|
|
1939
|
+
except Exception as _wexc:
|
|
1940
|
+
result = f"Error writing '{_wpath}': {type(_wexc).__name__}: {_wexc}"
|
|
1941
|
+
_invalidate_cache(file_cache, _wpath)
|
|
1942
|
+
|
|
1943
|
+
if not result.startswith("Error"):
|
|
1944
|
+
# The model wrote this content — it knows the file's state
|
|
1945
|
+
_record_read(file_cache, _wpath)
|
|
1946
|
+
# Feature 4: post-write syntax check
|
|
1947
|
+
_syn_content = _wcontent if _wmode == "w" else None
|
|
1948
|
+
_syn_err = _syntax_check(_wpath, _syn_content)
|
|
1949
|
+
if _syn_err:
|
|
1950
|
+
result += f"\n\n⚠ SYNTAX ERROR DETECTED: {_syn_err}"
|
|
1951
|
+
if not silent:
|
|
1952
|
+
console.print(
|
|
1953
|
+
f" [bold red]⚠ Syntax error:[/bold red] [dim]{_syn_err}[/dim]"
|
|
1954
|
+
)
|
|
1955
|
+
# Feature 7: change tracking
|
|
1956
|
+
if change_log is not None and _wpath:
|
|
1957
|
+
entry = change_log.get(_wpath, {"writes": 0, "edits": 0})
|
|
1958
|
+
entry["writes"] += 1
|
|
1959
|
+
change_log[_wpath] = entry
|
|
1960
|
+
elif name == "edit_file":
|
|
1961
|
+
from .diff import capture_edit
|
|
1962
|
+
_epath = str(args.get("path", ""))
|
|
1963
|
+
# Read-before-edit enforcement: block edits on files the model
|
|
1964
|
+
# never read this session, or that changed on disk since its
|
|
1965
|
+
# last read. Blind edits are the #1 cause of failed old_strings.
|
|
1966
|
+
_edit_block = _check_edit_allowed(file_cache, _epath)
|
|
1967
|
+
if _edit_block is not None:
|
|
1968
|
+
result = _edit_block
|
|
1969
|
+
if not silent:
|
|
1970
|
+
console.print(
|
|
1971
|
+
f" ⛔ [yellow]Edit blocked[/yellow] [cyan]{_epath}[/cyan] "
|
|
1972
|
+
f"[dim](read the file first)[/dim]"
|
|
1973
|
+
)
|
|
1974
|
+
history.append({"role": "tool", "tool_call_id": tc["id"], "content": result})
|
|
1975
|
+
continue
|
|
1976
|
+
# NOTE: never pre-check old_string against file_cache — the
|
|
1977
|
+
# cache stores line-numbered read_file output, so multi-line
|
|
1978
|
+
# old_strings can never match it. capture_edit works against
|
|
1979
|
+
# the real file on disk and returns nearest-match hints.
|
|
1980
|
+
result = capture_edit(
|
|
1981
|
+
_epath,
|
|
1982
|
+
args.get("old_string", ""),
|
|
1983
|
+
args.get("new_string", ""),
|
|
1984
|
+
replace_all=bool(args.get("replace_all", False)),
|
|
1985
|
+
)
|
|
1986
|
+
if result.startswith("Error") or result.startswith("File not found"):
|
|
1987
|
+
pass
|
|
1988
|
+
else:
|
|
1989
|
+
# The model made this change — it knows the file's new state
|
|
1990
|
+
_record_read(file_cache, _epath)
|
|
1991
|
+
# Refresh cache with updated content (complete files only)
|
|
1992
|
+
_refreshed = execute_tool("read_file", {"path": _epath})
|
|
1993
|
+
if (not _refreshed.startswith("Error")
|
|
1994
|
+
and not _refreshed.startswith("File not found")
|
|
1995
|
+
and "[file continues" not in _refreshed):
|
|
1996
|
+
_cache_put(file_cache, _epath, _refreshed)
|
|
1997
|
+
else:
|
|
1998
|
+
_invalidate_cache(file_cache, _epath)
|
|
1999
|
+
# Feature 4: post-edit syntax check (reads from disk)
|
|
2000
|
+
_syn_err = _syntax_check(_epath)
|
|
2001
|
+
if _syn_err:
|
|
2002
|
+
result += f"\n\n⚠ SYNTAX ERROR DETECTED: {_syn_err}"
|
|
2003
|
+
if not silent:
|
|
2004
|
+
console.print(
|
|
2005
|
+
f" [bold red]⚠ Syntax error:[/bold red] "
|
|
2006
|
+
f"[dim]{_syn_err}[/dim]"
|
|
2007
|
+
)
|
|
2008
|
+
# Feature 7: change tracking
|
|
2009
|
+
if change_log is not None:
|
|
2010
|
+
entry = change_log.get(_epath, {"writes": 0, "edits": 0})
|
|
2011
|
+
entry["edits"] += 1
|
|
2012
|
+
change_log[_epath] = entry
|
|
2013
|
+
elif name == "todo":
|
|
2014
|
+
result = _apply_todo(args, todo_state)
|
|
2015
|
+
if not silent and not result.startswith("Error"):
|
|
2016
|
+
_render_todos(todo_state)
|
|
2017
|
+
elif name == "spawn_agent":
|
|
2018
|
+
# Sequential single-call path (parallel calls are handled above)
|
|
2019
|
+
from .subagent import run_subagent, AGENT_TYPES
|
|
2020
|
+
sub_type = args.get("agent_type", "general")
|
|
2021
|
+
sub_task = args.get("task", "")
|
|
2022
|
+
if not sub_task:
|
|
2023
|
+
result = "Error: spawn_agent requires a 'task' argument."
|
|
2024
|
+
else:
|
|
2025
|
+
info = AGENT_TYPES.get(sub_type, AGENT_TYPES["general"])
|
|
2026
|
+
sub_result = run_subagent(
|
|
2027
|
+
task=sub_task,
|
|
2028
|
+
agent_type=sub_type,
|
|
2029
|
+
project_path=project_path,
|
|
2030
|
+
parent_system=system_content,
|
|
2031
|
+
parent_file_cache=file_cache,
|
|
2032
|
+
)
|
|
2033
|
+
if sub_result.success and sub_result.output:
|
|
2034
|
+
result = (
|
|
2035
|
+
f"[{info['label']} Agent — {sub_result.duration:.1f}s]\n\n"
|
|
2036
|
+
f"{sub_result.output}"
|
|
2037
|
+
)
|
|
2038
|
+
elif sub_result.error:
|
|
2039
|
+
result = f"[{info['label']} Agent failed: {sub_result.error}]"
|
|
2040
|
+
else:
|
|
2041
|
+
result = f"[{info['label']} Agent completed in {sub_result.duration:.1f}s — no output]"
|
|
2042
|
+
else:
|
|
2043
|
+
result = execute_tool(name, args)
|
|
2044
|
+
|
|
2045
|
+
elapsed = time.time() - t0
|
|
2046
|
+
hooks.run_post(call, result)
|
|
2047
|
+
session_cost.add_tool()
|
|
2048
|
+
if not silent:
|
|
2049
|
+
_show_tool_done(name, result, elapsed)
|
|
2050
|
+
|
|
2051
|
+
# Terminate signal: count tools that want to stop the loop
|
|
2052
|
+
if result == _TERMINATE_SENTINEL:
|
|
2053
|
+
_batch_term_count += 1
|
|
2054
|
+
result = "Agent signaled task completion."
|
|
2055
|
+
_batch_exec_count += 1
|
|
2056
|
+
|
|
2057
|
+
# Store full result in history — _build_api_messages compresses old ones
|
|
2058
|
+
history.append({
|
|
2059
|
+
"role": "tool",
|
|
2060
|
+
"tool_call_id": tc["id"],
|
|
2061
|
+
"content": _truncate_result(result),
|
|
2062
|
+
})
|
|
2063
|
+
|
|
2064
|
+
# If every executed tool in this batch signaled termination, exit the loop
|
|
2065
|
+
if _batch_exec_count > 0 and _batch_term_count == _batch_exec_count:
|
|
2066
|
+
if not silent:
|
|
2067
|
+
console.print(
|
|
2068
|
+
"\n [bold yellow]🛑 All tools signaled termination — stopping.[/bold yellow]\n"
|
|
2069
|
+
)
|
|
2070
|
+
break
|
|
2071
|
+
|
|
2072
|
+
if not silent:
|
|
2073
|
+
console.print()
|
|
2074
|
+
|
|
2075
|
+
console.print(
|
|
2076
|
+
f"\n [bold yellow]⚠ Reached {max_iter}-iteration limit.[/bold yellow] "
|
|
2077
|
+
"[dim]The task may be incomplete. Try /compact then re-send your task, "
|
|
2078
|
+
"or break it into smaller steps.[/dim]\n"
|
|
2079
|
+
)
|
|
2080
|
+
return f"Reached {max_iter}-iteration limit."
|
|
2081
|
+
|
|
2082
|
+
def _show_usage(prompt_tokens: int, completion_tokens: int):
|
|
2083
|
+
if prompt_tokens == 0:
|
|
2084
|
+
return
|
|
2085
|
+
total = prompt_tokens + completion_tokens
|
|
2086
|
+
console.print(
|
|
2087
|
+
f"\n[dim]Tokens: {prompt_tokens:,} in + {completion_tokens:,} out = {total:,} total[/dim]"
|
|
2088
|
+
)
|