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.
- msapling_cli/__init__.py +2 -0
- msapling_cli/agent.py +671 -0
- msapling_cli/api.py +394 -0
- msapling_cli/completer.py +415 -0
- msapling_cli/config.py +56 -0
- msapling_cli/local.py +133 -0
- msapling_cli/main.py +1038 -0
- msapling_cli/mcp/__init__.py +1 -0
- msapling_cli/mcp/server.py +411 -0
- msapling_cli/memory.py +97 -0
- msapling_cli/session.py +102 -0
- msapling_cli/shell.py +1583 -0
- msapling_cli/storage.py +265 -0
- msapling_cli/tier.py +78 -0
- msapling_cli/tui.py +475 -0
- msapling_cli/worker_pool.py +233 -0
- msapling_cli-0.1.2.dist-info/METADATA +132 -0
- msapling_cli-0.1.2.dist-info/RECORD +22 -0
- msapling_cli-0.1.2.dist-info/WHEEL +5 -0
- msapling_cli-0.1.2.dist-info/entry_points.txt +3 -0
- msapling_cli-0.1.2.dist-info/licenses/LICENSE +21 -0
- msapling_cli-0.1.2.dist-info/top_level.txt +1 -0
msapling_cli/__init__.py
ADDED
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)"
|