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/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
+ )