msapling-cli 0.1.2__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.
@@ -0,0 +1,2 @@
1
+ """MSapling CLI - Multi-chat AI development environment in your terminal."""
2
+ __version__ = "0.1.2"
msapling_cli/agent.py ADDED
@@ -0,0 +1,671 @@
1
+ """Agentic tool-use loop for MSapling CLI.
2
+
3
+ This is the core differentiator — instead of the user manually running
4
+ /read, /edit, /run, the AI decides what tools to use based on the conversation.
5
+
6
+ The agent loop:
7
+ 1. User sends a message
8
+ 2. LLM responds — may include tool calls in structured format
9
+ 3. CLI executes tool calls locally (read file, write file, run command, grep, glob)
10
+ 4. Tool results are fed back to the LLM
11
+ 5. Repeat until LLM responds with just text (no more tool calls)
12
+
13
+ This makes MSapling CLI behave like Claude Code — the AI autonomously
14
+ reads your code, edits files, and runs commands.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import html as html_module
20
+ import json
21
+ import os
22
+ import re
23
+ import subprocess
24
+ import uuid
25
+ from pathlib import Path
26
+ from typing import Any, Dict, List, Optional, Tuple
27
+ from urllib.parse import quote_plus
28
+
29
+ from rich.console import Console
30
+ from rich.panel import Panel
31
+ from rich.syntax import Syntax
32
+ from rich.status import Status
33
+
34
+ from .tui import (
35
+ console,
36
+ render_approval_prompt,
37
+ tool_header,
38
+ tool_result_display,
39
+ shimmer_text,
40
+ )
41
+
42
+ # Maximum agent loop iterations to prevent infinite loops
43
+ MAX_AGENT_STEPS = 15
44
+
45
+ # ─── Permission Profiles ─────────────────────────────────────────────
46
+ # Matches Claude Code / Codex CLI permission model:
47
+ # "ask" = Prompt for write/run tools (default, safe)
48
+ # "auto" = Auto-approve file edits within project, ask for commands
49
+ # "readonly" = Block all write tools, read-only browsing
50
+ # "full" = Approve everything without asking (like --yolo)
51
+ # "plan" = Same as readonly (used by plan mode)
52
+
53
+ _permission_mode: str = "ask"
54
+
55
+ # Per-tool session overrides (from "always" answers)
56
+ _permissions: Dict[str, bool] = {}
57
+
58
+ # Read-only tools that never require approval in any mode
59
+ _READ_ONLY_TOOLS = frozenset({"read_file", "glob_files", "grep_search", "web_search", "cost_check"})
60
+
61
+ # Write tools blocked in readonly/plan mode
62
+ _WRITE_TOOLS = frozenset({"write_file", "edit_file", "run_command"})
63
+
64
+
65
+ def reset_permissions() -> None:
66
+ """Reset all session permissions (called at session start)."""
67
+ global _permission_mode
68
+ _permissions.clear()
69
+ _permission_mode = "ask"
70
+
71
+
72
+ def set_permission_mode(mode: str) -> str:
73
+ """Set the permission profile. Returns the active mode."""
74
+ global _permission_mode
75
+ valid = ("ask", "auto", "readonly", "full", "plan")
76
+ if mode not in valid:
77
+ return _permission_mode
78
+ _permission_mode = mode
79
+ return _permission_mode
80
+
81
+
82
+ def get_permission_mode() -> str:
83
+ return _permission_mode
84
+
85
+
86
+ def _truncated_lines(text: str, max_lines: int) -> str:
87
+ lines = text.splitlines()
88
+ if len(lines) <= max_lines:
89
+ return text
90
+ return "\n".join(lines[:max_lines]) + f"\n ... ({len(lines) - max_lines} more lines)"
91
+
92
+
93
+ def _prompt_for_approval(tool_name: str, args: Dict[str, Any]) -> str:
94
+ """Show a styled preview and ask for approval. Returns 'y', 'n', or 'always'."""
95
+ return render_approval_prompt(tool_name, args)
96
+
97
+ # Tools the agent can use — matches Claude Code's tool set
98
+ AGENT_TOOLS_SCHEMA = [
99
+ {
100
+ "name": "read_file",
101
+ "description": "Read a file's contents. Use this to understand code before making changes.",
102
+ "parameters": {"path": "string (relative file path)", "offset": "int (optional, start line)", "limit": "int (optional, max lines)"},
103
+ },
104
+ {
105
+ "name": "write_file",
106
+ "description": "Create or overwrite a file with new content.",
107
+ "parameters": {"path": "string (relative file path)", "content": "string (full file content)"},
108
+ },
109
+ {
110
+ "name": "edit_file",
111
+ "description": "Make a targeted edit to a file by replacing a specific string. Use this instead of write_file when making small changes.",
112
+ "parameters": {"path": "string (relative file path)", "old_string": "string (exact text to find)", "new_string": "string (replacement text)"},
113
+ },
114
+ {
115
+ "name": "run_command",
116
+ "description": "Execute a shell command and return its output. Use for running tests, builds, git operations, etc.",
117
+ "parameters": {"command": "string (the command to run)"},
118
+ },
119
+ {
120
+ "name": "glob_files",
121
+ "description": "Find files matching a glob pattern. Use to discover project structure.",
122
+ "parameters": {"pattern": "string (glob pattern like '**/*.py' or 'src/**/*.ts')"},
123
+ },
124
+ {
125
+ "name": "grep_search",
126
+ "description": "Search file contents for a regex pattern. Returns matching lines with file paths and line numbers.",
127
+ "parameters": {"pattern": "string (regex pattern)", "path": "string (optional, directory to search, default '.')"},
128
+ },
129
+ {
130
+ "name": "web_search",
131
+ "description": "Search the web for current information. Uses MSapling's backend search. Use when the user asks about recent events, documentation, or anything that might be outdated in your training data.",
132
+ "parameters": {"query": "string (search query)"},
133
+ },
134
+ {
135
+ "name": "cost_check",
136
+ "description": "Check the user's remaining fuel credits and session cost before expensive operations. Use this before large file writes or multi-step tasks.",
137
+ "parameters": {},
138
+ },
139
+ {
140
+ "name": "model_switch",
141
+ "description": "Switch to a different LLM model mid-task. Use a cheaper model (gemini-flash, gpt-4o-mini) for grunt work like formatting or boilerplate, and a premium model (claude-3.5-sonnet, gpt-4o) for complex reasoning.",
142
+ "parameters": {"model": "string (model ID like 'google/gemini-2.0-flash-001' or 'openai/gpt-4o')"},
143
+ },
144
+ ]
145
+
146
+ AGENT_SYSTEM_PROMPT = """You are MSapling, an AI coding assistant running in a CLI terminal.
147
+ You have access to tools for reading files, writing files, editing code, running commands, searching, and cost management.
148
+
149
+ When the user asks you to do something with code:
150
+ 1. First READ the relevant files to understand the code
151
+ 2. Then EDIT or WRITE files to make changes
152
+ 3. Then RUN tests or commands to verify
153
+
154
+ To use a tool, respond with a JSON block in this format:
155
+ ```tool
156
+ {"tool": "tool_name", "args": {"param1": "value1"}}
157
+ ```
158
+
159
+ Available tools:
160
+ - read_file: Read a file. Args: path, offset (optional), limit (optional)
161
+ - write_file: Create/overwrite a file. Args: path, content
162
+ - edit_file: Replace a specific string in a file. Args: path, old_string, new_string
163
+ - run_command: Execute a shell command. Args: command
164
+ - glob_files: Find files by pattern. Args: pattern
165
+ - grep_search: Search file contents. Args: pattern, path (optional)
166
+ - web_search: Search the web via MSapling backend. Args: query
167
+ - cost_check: Check remaining fuel credits and session cost. No args.
168
+ - model_switch: Switch to a different model mid-task. Args: model (e.g. "google/gemini-2.0-flash-001")
169
+
170
+ Cost tips: Use cost_check before expensive multi-step tasks. Use model_switch to use cheaper models for boilerplate.
171
+
172
+ You can use multiple tools in one response. After tool results, continue reasoning.
173
+ When you're done (no more tools needed), just respond with your final message.
174
+
175
+ Keep responses concise. Use diffs when showing code changes.
176
+ """
177
+
178
+
179
+ def _safe_path(project_root: str, rel_path: str) -> Optional[Path]:
180
+ """Validate path stays within project root."""
181
+ try:
182
+ resolved = (Path(project_root) / rel_path.strip()).resolve()
183
+ root_resolved = Path(project_root).resolve()
184
+ if resolved == root_resolved or resolved.is_relative_to(root_resolved):
185
+ return resolved
186
+ except Exception:
187
+ pass
188
+ return None
189
+
190
+
191
+ def _extract_tool_calls(text: str) -> Tuple[str, List[Dict[str, Any]]]:
192
+ """Extract tool call blocks from LLM response text.
193
+
194
+ Returns (clean_text, tool_calls) where clean_text has tool blocks removed.
195
+ """
196
+ tool_calls = []
197
+ clean_parts = []
198
+
199
+ # Match ```tool ... ``` blocks
200
+ pattern = r'```tool\s*\n(.*?)\n```'
201
+ last_end = 0
202
+ for match in re.finditer(pattern, text, re.DOTALL):
203
+ clean_parts.append(text[last_end:match.start()])
204
+ try:
205
+ tool_data = json.loads(match.group(1).strip())
206
+ if isinstance(tool_data, dict) and "tool" in tool_data:
207
+ tool_calls.append(tool_data)
208
+ except json.JSONDecodeError:
209
+ clean_parts.append(match.group(0)) # Keep invalid blocks as text
210
+ last_end = match.end()
211
+ clean_parts.append(text[last_end:])
212
+
213
+ # Also try inline JSON tool calls (fallback)
214
+ if not tool_calls:
215
+ for match in re.finditer(r'\{"tool"\s*:\s*"(\w+)"[^}]*\}', text):
216
+ try:
217
+ tool_data = json.loads(match.group(0))
218
+ if "tool" in tool_data:
219
+ tool_calls.append(tool_data)
220
+ except json.JSONDecodeError:
221
+ continue
222
+
223
+ return "".join(clean_parts).strip(), tool_calls
224
+
225
+
226
+ def _check_approval(tool_name: str, args: Dict[str, Any]) -> Optional[str]:
227
+ """Check whether the user approves executing this tool.
228
+
229
+ Returns ``None`` if approved (proceed), or a skip message string.
230
+
231
+ Permission modes:
232
+ ask — prompt for write/run tools
233
+ auto — auto-approve edits in project, prompt for run_command
234
+ readonly — block all write tools
235
+ full — approve everything silently
236
+ plan — same as readonly
237
+ """
238
+ # Read-only tools always pass
239
+ if tool_name in _READ_ONLY_TOOLS:
240
+ return None
241
+
242
+ # readonly / plan mode: block all write tools
243
+ if _permission_mode in ("readonly", "plan"):
244
+ if tool_name in _WRITE_TOOLS:
245
+ return f"Blocked: {tool_name} not allowed in {_permission_mode} mode"
246
+ return None
247
+
248
+ # full mode: approve everything
249
+ if _permission_mode == "full":
250
+ return None
251
+
252
+ # auto mode: approve file edits silently, ask for commands
253
+ if _permission_mode == "auto":
254
+ if tool_name in ("write_file", "edit_file"):
255
+ return None # auto-approve file edits
256
+ if tool_name == "run_command":
257
+ # Still ask for commands (safety)
258
+ pass # Fall through to prompt
259
+ else:
260
+ return None
261
+
262
+ # Per-tool session override (from previous "always" answer)
263
+ if _permissions.get(tool_name):
264
+ return None
265
+
266
+ # ask mode (default): prompt user
267
+ answer = _prompt_for_approval(tool_name, args)
268
+ if answer == "always":
269
+ _permissions[tool_name] = True
270
+ return None
271
+ if answer == "y":
272
+ return None
273
+ return "Skipped by user"
274
+
275
+
276
+ def _web_search(query: str) -> str:
277
+ """Search the web using DuckDuckGo Lite (HTML) and extract results.
278
+
279
+ Tries the MSapling backend /api/tools/web-research endpoint first.
280
+ Falls back to a direct DuckDuckGo Lite HTML scrape via httpx.
281
+ """
282
+ import httpx as _httpx
283
+
284
+ # --- Attempt 1: MSapling backend web-research endpoint ---
285
+ try:
286
+ from .config import get_settings, get_token
287
+ settings = get_settings()
288
+ api_url = settings.api_url.rstrip("/")
289
+ token = get_token()
290
+ cookies = {"msaplingauth": token} if token else {}
291
+ search_url = f"https://www.google.com/search?q={quote_plus(query)}"
292
+ resp = _httpx.post(
293
+ f"{api_url}/api/tools/web-research",
294
+ json={"url": search_url},
295
+ cookies=cookies,
296
+ timeout=15.0,
297
+ )
298
+ if resp.status_code == 200:
299
+ data = resp.json()
300
+ content = data.get("content", data.get("text", ""))
301
+ if content and len(content) > 20:
302
+ # Truncate to keep context reasonable
303
+ return content[:8000]
304
+ except Exception:
305
+ pass # Fall through to DuckDuckGo
306
+
307
+ # --- Attempt 2: DuckDuckGo Lite HTML scrape ---
308
+ try:
309
+ resp = _httpx.get(
310
+ "https://lite.duckduckgo.com/lite/",
311
+ params={"q": query},
312
+ headers={"User-Agent": "MSapling-CLI/0.1"},
313
+ timeout=10.0,
314
+ follow_redirects=True,
315
+ )
316
+ if resp.status_code != 200:
317
+ return f"Web search failed (HTTP {resp.status_code})"
318
+
319
+ body = resp.text
320
+ results = []
321
+
322
+ # Extract result snippets from DuckDuckGo Lite HTML
323
+ # DDG Lite uses <a class="result-link"> and <td class="result-snippet">
324
+ link_pattern = re.compile(
325
+ r'<a[^>]+class="result-link"[^>]*href="([^"]+)"[^>]*>(.*?)</a>',
326
+ re.DOTALL,
327
+ )
328
+ snippet_pattern = re.compile(
329
+ r'<td\s+class="result-snippet">(.*?)</td>',
330
+ re.DOTALL,
331
+ )
332
+
333
+ links = link_pattern.findall(body)
334
+ snippets = snippet_pattern.findall(body)
335
+
336
+ for i, (url, title_html) in enumerate(links[:8]):
337
+ title = re.sub(r"<[^>]+>", "", title_html).strip()
338
+ title = html_module.unescape(title)
339
+ snippet = ""
340
+ if i < len(snippets):
341
+ snippet = re.sub(r"<[^>]+>", "", snippets[i]).strip()
342
+ snippet = html_module.unescape(snippet)
343
+ results.append(f"{i+1}. {title}\n {url}\n {snippet}")
344
+
345
+ if results:
346
+ return f"Web search results for: {query}\n\n" + "\n\n".join(results)
347
+
348
+ # Fallback: extract any text content from the page
349
+ text = re.sub(r"<[^>]+>", " ", body)
350
+ text = re.sub(r"\s+", " ", text).strip()
351
+ if text:
352
+ return f"Web search results for: {query}\n\n{text[:4000]}"
353
+
354
+ return "Web search returned no results."
355
+ except Exception as e:
356
+ return f"Web search error: {e}"
357
+
358
+
359
+ def _execute_tool(
360
+ tool_name: str,
361
+ args: Dict[str, Any],
362
+ project_root: str,
363
+ hooks_mgr: Any = None,
364
+ model: str = "",
365
+ ) -> str:
366
+ """Execute a single tool call locally. Returns result as string.
367
+
368
+ When *hooks_mgr* is provided (a HooksManager), the function fires
369
+ *on_file_write* hooks after a successful write_file or edit_file.
370
+ """
371
+
372
+ # --- Approval gate for mutating tools ---
373
+ skip_msg = _check_approval(tool_name, args)
374
+ if skip_msg is not None:
375
+ return skip_msg
376
+
377
+ if tool_name == "read_file":
378
+ path = args.get("path", "")
379
+ fpath = _safe_path(project_root, path)
380
+ if not fpath:
381
+ return f"Error: path '{path}' is outside project root"
382
+ if not fpath.is_file():
383
+ return f"Error: file not found: {path}"
384
+ try:
385
+ content = fpath.read_text(encoding="utf-8", errors="ignore")
386
+ offset = int(args.get("offset", 0))
387
+ limit = int(args.get("limit", 0))
388
+ if offset or limit:
389
+ lines = content.splitlines(keepends=True)
390
+ start = max(0, offset - 1) if offset else 0
391
+ end = start + limit if limit else len(lines)
392
+ content = "".join(lines[start:end])
393
+ if len(content) > 50000:
394
+ content = content[:50000] + "\n... [truncated at 50KB]"
395
+ return content
396
+ except Exception as e:
397
+ return f"Error reading {path}: {e}"
398
+
399
+ elif tool_name == "write_file":
400
+ path = args.get("path", "")
401
+ content = args.get("content", "")
402
+ fpath = _safe_path(project_root, path)
403
+ if not fpath:
404
+ return f"Error: path '{path}' is outside project root"
405
+ try:
406
+ fpath.parent.mkdir(parents=True, exist_ok=True)
407
+ fpath.write_text(content, encoding="utf-8")
408
+ if hooks_mgr is not None:
409
+ hooks_mgr.run(
410
+ "on_file_write", project_root,
411
+ project=project_root, model=model,
412
+ file=str(fpath), tool="write_file",
413
+ )
414
+ return f"Wrote {len(content)} bytes to {path}"
415
+ except Exception as e:
416
+ return f"Error writing {path}: {e}"
417
+
418
+ elif tool_name == "edit_file":
419
+ path = args.get("path", "")
420
+ old_string = args.get("old_string", "")
421
+ new_string = args.get("new_string", "")
422
+ fpath = _safe_path(project_root, path)
423
+ if not fpath:
424
+ return f"Error: path '{path}' is outside project root"
425
+ if not fpath.is_file():
426
+ return f"Error: file not found: {path}"
427
+ try:
428
+ content = fpath.read_text(encoding="utf-8", errors="ignore")
429
+ if old_string not in content:
430
+ return f"Error: old_string not found in {path}. The file may have changed."
431
+ # Backup before editing (like Claude Code's file-history)
432
+ try:
433
+ from .storage import backup_file
434
+ backup_file(path, content, "agent")
435
+ except Exception:
436
+ pass
437
+ new_content = content.replace(old_string, new_string, 1)
438
+ fpath.write_text(new_content, encoding="utf-8")
439
+ if hooks_mgr is not None:
440
+ hooks_mgr.run(
441
+ "on_file_write", project_root,
442
+ project=project_root, model=model,
443
+ file=str(fpath), tool="edit_file",
444
+ )
445
+ return f"Edited {path}: replaced {len(old_string)} chars with {len(new_string)} chars"
446
+ except Exception as e:
447
+ return f"Error editing {path}: {e}"
448
+
449
+ elif tool_name == "run_command":
450
+ command = args.get("command", "")
451
+ if not command:
452
+ return "Error: no command provided"
453
+ try:
454
+ result = subprocess.run(
455
+ command, shell=True, cwd=project_root,
456
+ capture_output=True, text=True, timeout=30,
457
+ )
458
+ output = ""
459
+ if result.stdout:
460
+ output += result.stdout[:10000]
461
+ if result.stderr:
462
+ output += f"\nSTDERR:\n{result.stderr[:5000]}"
463
+ output += f"\n(exit code: {result.returncode})"
464
+ return output or "(no output)"
465
+ except subprocess.TimeoutExpired:
466
+ return "Error: command timed out (30s limit)"
467
+ except Exception as e:
468
+ return f"Error running command: {e}"
469
+
470
+ elif tool_name == "glob_files":
471
+ pattern = args.get("pattern", "")
472
+ import glob as globmod
473
+ try:
474
+ skip = {".git", "node_modules", "__pycache__", "venv", "dist", "build", ".next"}
475
+ matches = []
476
+ for f in sorted(globmod.glob(str(Path(project_root) / pattern), recursive=True)):
477
+ rel = os.path.relpath(f, project_root).replace("\\", "/")
478
+ if any(s in rel.split("/") for s in skip):
479
+ continue
480
+ if os.path.isfile(f):
481
+ matches.append(rel)
482
+ if len(matches) >= 50:
483
+ break
484
+ return "\n".join(matches) if matches else "No files matched"
485
+ except Exception as e:
486
+ return f"Error: {e}"
487
+
488
+ elif tool_name == "grep_search":
489
+ pattern = args.get("pattern", "")
490
+ search_path = args.get("path", ".")
491
+ try:
492
+ # Try ripgrep first, then grep
493
+ for cmd in [
494
+ ["rg", "-n", "--max-count=30", "-g", "!.git", "-g", "!node_modules", pattern, search_path],
495
+ ["grep", "-rn", "--exclude-dir=.git", "--exclude-dir=node_modules", pattern, search_path],
496
+ ]:
497
+ try:
498
+ result = subprocess.run(
499
+ cmd, cwd=project_root, capture_output=True, text=True, timeout=10,
500
+ )
501
+ return result.stdout[:8000] if result.stdout else "No matches found"
502
+ except FileNotFoundError:
503
+ continue
504
+ return "Error: neither rg (ripgrep) nor grep found"
505
+ except Exception as e:
506
+ return f"Error: {e}"
507
+
508
+ elif tool_name == "web_search":
509
+ query = args.get("query", "")
510
+ if not query:
511
+ return "Error: no search query provided"
512
+ return _web_search(query)
513
+
514
+ elif tool_name == "cost_check":
515
+ try:
516
+ from .config import get_settings, get_token
517
+ from .api import MSaplingClient
518
+ import asyncio as _asyncio
519
+
520
+ async def _check():
521
+ client = MSaplingClient()
522
+ try:
523
+ user = await client.me()
524
+ fuel = user.get("fuel_credits", 0)
525
+ tier = user.get("tier", "free")
526
+ return (
527
+ f"Tier: {tier}\n"
528
+ f"Fuel credits remaining: ${fuel:.4f}\n"
529
+ f"Pro: {user.get('is_pro', False)}"
530
+ )
531
+ finally:
532
+ await client.close()
533
+
534
+ return _asyncio.get_event_loop().run_until_complete(_check())
535
+ except Exception as e:
536
+ return f"Cost check error: {e}"
537
+
538
+ elif tool_name == "model_switch":
539
+ new_model = args.get("model", "")
540
+ if not new_model:
541
+ return "Error: provide a model ID (e.g. 'google/gemini-2.0-flash-001')"
542
+ # Store in a module-level var that the shell can read
543
+ global _switched_model
544
+ _switched_model = new_model
545
+ return f"Model switched to: {new_model} (takes effect on next LLM call)"
546
+
547
+ return f"Unknown tool: {tool_name}"
548
+
549
+ # Module-level var for model_switch tool — shell reads this after agent loop
550
+ _switched_model: Optional[str] = None
551
+
552
+ def get_and_clear_switched_model() -> Optional[str]:
553
+ """Return the model set by model_switch tool and clear it."""
554
+ global _switched_model
555
+ m = _switched_model
556
+ _switched_model = None
557
+ return m
558
+
559
+
560
+ async def run_agent_loop(
561
+ client,
562
+ prompt: str,
563
+ model: str,
564
+ project_root: str,
565
+ messages: List[Dict[str, str]],
566
+ on_text: Any = None,
567
+ on_usage: Any = None,
568
+ hooks_mgr: Any = None,
569
+ ) -> str:
570
+ """Run the agentic tool-use loop.
571
+
572
+ The LLM receives the user's message plus tool definitions.
573
+ If it responds with tool calls, we execute them and feed results back.
574
+ Loop continues until the LLM responds with just text.
575
+
576
+ Args:
577
+ client: MSaplingClient instance
578
+ prompt: User's message
579
+ model: Model ID
580
+ project_root: Path to project root
581
+ messages: Conversation history (mutated in place)
582
+ on_text: Optional callback(text) for streaming display
583
+ on_usage: Optional callback(tokens, cost) for usage accounting
584
+ hooks_mgr: Optional HooksManager for lifecycle hooks
585
+
586
+ Returns:
587
+ Final assistant response text
588
+ """
589
+ # Inject agent system prompt if not already present
590
+ has_agent_prompt = any(
591
+ m.get("role") == "system" and "tool" in m.get("content", "").lower()
592
+ for m in messages
593
+ )
594
+ if not has_agent_prompt:
595
+ messages.insert(0, {"role": "system", "content": AGENT_SYSTEM_PROMPT})
596
+
597
+ messages.append({"role": "user", "content": prompt})
598
+
599
+ for step in range(MAX_AGENT_STEPS):
600
+ # Get LLM response
601
+ parts = []
602
+ async for chunk in client.stream_chat(
603
+ chat_id=str(uuid.uuid4())[:12],
604
+ prompt=prompt if step == 0 else "",
605
+ model=model,
606
+ history=messages[-30:], # Keep recent context
607
+ ):
608
+ content = chunk.get("content", "")
609
+ if content:
610
+ parts.append(content)
611
+ if on_text:
612
+ on_text("".join(parts))
613
+ if chunk.get("type") == "usage" and on_usage:
614
+ usage = chunk.get("usage", {})
615
+ on_usage(usage.get("total_tokens", 0), float(chunk.get("cost", 0)))
616
+
617
+ response_text = "".join(parts)
618
+ if not response_text:
619
+ break
620
+
621
+ # Extract tool calls
622
+ clean_text, tool_calls = _extract_tool_calls(response_text)
623
+
624
+ if not tool_calls:
625
+ # No tools — final response
626
+ messages.append({"role": "assistant", "content": response_text})
627
+ return clean_text or response_text
628
+
629
+ # Execute tools
630
+ messages.append({"role": "assistant", "content": response_text})
631
+ tool_results = []
632
+
633
+ for tc in tool_calls:
634
+ tool_name = tc.get("tool", "")
635
+ tool_args = tc.get("args", {})
636
+
637
+ # Show tool execution header (styled)
638
+ console.print(tool_header(tool_name, tool_args))
639
+
640
+ # on_before_tool hook
641
+ if hooks_mgr is not None:
642
+ hooks_mgr.run(
643
+ "on_before_tool", project_root,
644
+ project=project_root, model=model,
645
+ tool=tool_name,
646
+ )
647
+
648
+ result = _execute_tool(
649
+ tool_name, tool_args, project_root,
650
+ hooks_mgr=hooks_mgr, model=model,
651
+ )
652
+
653
+ # on_after_tool hook
654
+ if hooks_mgr is not None:
655
+ hooks_mgr.run(
656
+ "on_after_tool", project_root,
657
+ project=project_root, model=model,
658
+ tool=tool_name,
659
+ )
660
+
661
+ # Show styled result
662
+ console.print(tool_result_display(tool_name, result, tool_args))
663
+
664
+ tool_results.append(f"[Tool: {tool_name}]\n{result}")
665
+
666
+ # Feed results back to LLM
667
+ tool_feedback = "\n\n".join(tool_results)
668
+ messages.append({"role": "user", "content": f"[Tool results]\n{tool_feedback}"})
669
+ prompt = "" # Empty prompt for continuation
670
+
671
+ return "(Agent reached maximum steps)"