zai-cli 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.
- zai/__init__.py +1 -0
- zai/__main__.py +4 -0
- zai/cli/__init__.py +1 -0
- zai/cli/common.py +16 -0
- zai/cli/integrations.py +319 -0
- zai/cli/interactive.py +518 -0
- zai/cli/settings.py +436 -0
- zai/cli/utilities.py +227 -0
- zai/cli/workflows.py +137 -0
- zai/commands/commit.md +24 -0
- zai/commands/explain.md +17 -0
- zai/commands/feature.md +34 -0
- zai/commands/fix.md +14 -0
- zai/commands/review.md +22 -0
- zai/config.py +307 -0
- zai/core/__init__.py +0 -0
- zai/core/agent.py +701 -0
- zai/core/cancellation.py +67 -0
- zai/core/commands.py +85 -0
- zai/core/context.py +299 -0
- zai/core/errors.py +125 -0
- zai/core/fallback.py +171 -0
- zai/core/hooks.py +115 -0
- zai/core/memory.py +57 -0
- zai/core/process.py +204 -0
- zai/core/repomap.py +381 -0
- zai/core/runtime.py +29 -0
- zai/core/security.py +33 -0
- zai/core/session.py +425 -0
- zai/core/storage.py +193 -0
- zai/core/streaming.py +157 -0
- zai/core/tool_schema.py +133 -0
- zai/core/undo.py +443 -0
- zai/core/watch.py +80 -0
- zai/main.py +210 -0
- zai/mcp/__init__.py +0 -0
- zai/mcp/client.py +431 -0
- zai/mcp/manager.py +118 -0
- zai/plugins/__init__.py +2 -0
- zai/plugins/base.py +49 -0
- zai/plugins/loader.py +404 -0
- zai/providers/__init__.py +22 -0
- zai/providers/anthropic.py +131 -0
- zai/providers/base.py +67 -0
- zai/providers/cerebras.py +57 -0
- zai/providers/gemini.py +119 -0
- zai/providers/groq.py +116 -0
- zai/providers/ollama.py +62 -0
- zai/providers/openai.py +124 -0
- zai/providers/openrouter.py +63 -0
- zai/providers/qwen.py +47 -0
- zai/skills/__init__.py +0 -0
- zai/skills/registry.py +52 -0
- zai/tools/__init__.py +0 -0
- zai/tools/browser.py +224 -0
- zai/tools/code_runner.py +49 -0
- zai/tools/files.py +53 -0
- zai/tools/git.py +38 -0
- zai/tools/search.py +157 -0
- zai/tools/vision.py +128 -0
- zai/ui/__init__.py +0 -0
- zai/ui/input.py +199 -0
- zai_cli-0.1.0.dist-info/METADATA +722 -0
- zai_cli-0.1.0.dist-info/RECORD +68 -0
- zai_cli-0.1.0.dist-info/WHEEL +5 -0
- zai_cli-0.1.0.dist-info/entry_points.txt +2 -0
- zai_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- zai_cli-0.1.0.dist-info/top_level.txt +1 -0
zai/core/agent.py
ADDED
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import json
|
|
4
|
+
import html
|
|
5
|
+
import difflib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.prompt import Confirm
|
|
9
|
+
from .fallback import format_model_selection
|
|
10
|
+
from .cancellation import OperationCancelled, operation, raise_if_cancelled
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
# Legacy in-memory marker retained for compatibility with existing integrations.
|
|
15
|
+
_file_backups: dict = {}
|
|
16
|
+
|
|
17
|
+
# Session token counter
|
|
18
|
+
_session_tokens: int = 0
|
|
19
|
+
|
|
20
|
+
LEGACY_AGENT_SYSTEM = """You are zai, a smart AI coding assistant in the terminal.
|
|
21
|
+
|
|
22
|
+
TOOLS (only use when the user asks you to act on files or run something):
|
|
23
|
+
|
|
24
|
+
Emit each tool call as validated JSON inside a <tool_call> wrapper:
|
|
25
|
+
<tool_call>
|
|
26
|
+
{"name":"write_file","arguments":{"path":"folder/file.txt","content":"file contents"}}
|
|
27
|
+
</tool_call>
|
|
28
|
+
|
|
29
|
+
Available names and arguments:
|
|
30
|
+
- write_file: {"path": string, "content": string}
|
|
31
|
+
- read_file: {"path": string}
|
|
32
|
+
- edit_file: {"path": string, "search": string, "replace": string}
|
|
33
|
+
- create_folder: {"path": string}
|
|
34
|
+
- rename_path: {"source": string, "destination": string}
|
|
35
|
+
- list_files: {"path": string} (path defaults to ".")
|
|
36
|
+
- run_command: {"command": string}
|
|
37
|
+
- mcp_call: {"server": string, "tool": string, "arguments": object}
|
|
38
|
+
- plugin_call: {"plugin": string, "tool": string, "arguments": object}
|
|
39
|
+
|
|
40
|
+
RULES:
|
|
41
|
+
- For greetings, questions, or explanations → just reply in plain text, NO tools
|
|
42
|
+
- Only use tools when user explicitly asks to create / edit / run something
|
|
43
|
+
- When modifying an existing file → use edit_file, not write_file
|
|
44
|
+
- Never show code in markdown blocks — use write_file to actually create it
|
|
45
|
+
- JSON must be valid: double quotes, no comments, and no trailing commas
|
|
46
|
+
- After tools run, do NOT repeat or confirm what was done — the output already shows it
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
AGENT_SYSTEM = """You are zai, a smart AI coding assistant in the terminal.
|
|
50
|
+
|
|
51
|
+
Use the tools supplied by the API only when the user asks you to act on files,
|
|
52
|
+
inspect project state, or run something.
|
|
53
|
+
|
|
54
|
+
RULES:
|
|
55
|
+
- For greetings, questions, or explanations, reply in plain text without tools
|
|
56
|
+
- Only use tools when the user asks to create, edit, inspect, or run something
|
|
57
|
+
- When modifying an existing file, use edit_file rather than write_file
|
|
58
|
+
- Never show code in markdown when the user asked you to create the file
|
|
59
|
+
- Tool arguments must match the supplied JSON schema exactly
|
|
60
|
+
- After tools run, do not repeat what the tool output already showed
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
PLAN_SYSTEM = """You are zai in PLAN MODE. Analyze what the user wants to accomplish.
|
|
64
|
+
DO NOT emit tool calls. DO NOT create or modify any files yet.
|
|
65
|
+
Write a numbered plan describing exactly what you will do, file by file.
|
|
66
|
+
Be specific: mention each file name, what will be created or changed.
|
|
67
|
+
End your response with exactly: "Ready to execute. Type 'yes' to proceed."
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _parse_attrs(attrs_str: str) -> dict:
|
|
72
|
+
return dict(re.findall(r'(\w+)=["\']([^"\']*)["\']', attrs_str))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _show_diff(old: str, new: str, path: str):
|
|
76
|
+
"""Display colored unified diff between old and new content."""
|
|
77
|
+
old_lines = old.splitlines(keepends=True)
|
|
78
|
+
new_lines = new.splitlines(keepends=True)
|
|
79
|
+
diff = list(difflib.unified_diff(
|
|
80
|
+
old_lines, new_lines,
|
|
81
|
+
fromfile=f"before/{path}", tofile=f"after/{path}", n=2
|
|
82
|
+
))
|
|
83
|
+
if not diff:
|
|
84
|
+
return
|
|
85
|
+
for line in diff[:80]:
|
|
86
|
+
line = line.rstrip()
|
|
87
|
+
if line.startswith('+') and not line.startswith('+++'):
|
|
88
|
+
console.print(f"[green]{line}[/green]")
|
|
89
|
+
elif line.startswith('-') and not line.startswith('---'):
|
|
90
|
+
console.print(f"[red]{line}[/red]")
|
|
91
|
+
elif line.startswith('@@'):
|
|
92
|
+
console.print(f"[cyan]{line}[/cyan]")
|
|
93
|
+
else:
|
|
94
|
+
console.print(f"[dim]{line}[/dim]")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _verify_file_content(path: Path, expected: str) -> bool:
|
|
98
|
+
"""Verify that a text file exists and contains exactly what was requested."""
|
|
99
|
+
try:
|
|
100
|
+
return path.is_file() and path.read_text(
|
|
101
|
+
encoding="utf-8", errors="ignore"
|
|
102
|
+
) == expected
|
|
103
|
+
except OSError:
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _rollback_file(path: Path, previous_content: str | None) -> None:
|
|
108
|
+
"""Best-effort rollback for a file mutation that failed verification."""
|
|
109
|
+
try:
|
|
110
|
+
if previous_content is None:
|
|
111
|
+
if path.is_file():
|
|
112
|
+
path.unlink()
|
|
113
|
+
else:
|
|
114
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
from .storage import atomic_write_text
|
|
116
|
+
|
|
117
|
+
atomic_write_text(
|
|
118
|
+
path,
|
|
119
|
+
previous_content,
|
|
120
|
+
mode=0o644,
|
|
121
|
+
lock=False,
|
|
122
|
+
)
|
|
123
|
+
except OSError:
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _execute_tool(tag: str, attrs: dict, content: str, cwd: str) -> str:
|
|
128
|
+
raise_if_cancelled()
|
|
129
|
+
from .hooks import fire, check_security
|
|
130
|
+
|
|
131
|
+
if not fire("PreToolUse", {"tool": tag, "attrs": attrs, "content": content[:200]}):
|
|
132
|
+
return f"Tool {tag} blocked by hook"
|
|
133
|
+
|
|
134
|
+
result = _execute_tool_inner(tag, attrs, content, cwd)
|
|
135
|
+
|
|
136
|
+
fire("PostToolUse", {"tool": tag, "result": result[:200]})
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _execute_tool_inner(tag: str, attrs: dict, content: str, cwd: str) -> str:
|
|
141
|
+
from .hooks import check_security
|
|
142
|
+
from .security import resolve_project_path
|
|
143
|
+
from .process import run_direct
|
|
144
|
+
from .errors import FileError
|
|
145
|
+
from .undo import record_created_folder, record_file_change, record_rename
|
|
146
|
+
from .storage import atomic_write_text
|
|
147
|
+
|
|
148
|
+
if tag == "write_file":
|
|
149
|
+
path = attrs.get("path", "")
|
|
150
|
+
if not path:
|
|
151
|
+
return "Error: no path given"
|
|
152
|
+
file_content = html.unescape(content)
|
|
153
|
+
warnings = check_security(file_content)
|
|
154
|
+
if warnings:
|
|
155
|
+
for w in warnings:
|
|
156
|
+
console.print(f"[yellow] Security: {w}[/yellow]")
|
|
157
|
+
try:
|
|
158
|
+
fpath = resolve_project_path(cwd, path)
|
|
159
|
+
except FileError as e:
|
|
160
|
+
return f"Error: {e}"
|
|
161
|
+
previous_content = None
|
|
162
|
+
existed = fpath.exists()
|
|
163
|
+
# Show diff and save backup if file exists
|
|
164
|
+
if existed:
|
|
165
|
+
if not fpath.is_file():
|
|
166
|
+
return f"Error: not a file: {path}"
|
|
167
|
+
old_content = fpath.read_text(encoding="utf-8", errors="ignore")
|
|
168
|
+
previous_content = old_content
|
|
169
|
+
if old_content != file_content:
|
|
170
|
+
console.print(f"[yellow] Modifying:[/yellow] {path}")
|
|
171
|
+
_show_diff(old_content, file_content, path)
|
|
172
|
+
else:
|
|
173
|
+
return f"File unchanged: {path}"
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
fpath.parent.mkdir(parents=True, exist_ok=True)
|
|
177
|
+
atomic_write_text(fpath, file_content, mode=0o644, lock=False)
|
|
178
|
+
except OSError as e:
|
|
179
|
+
return f"Error: could not write {path}: {e}"
|
|
180
|
+
if not _verify_file_content(fpath, file_content):
|
|
181
|
+
_rollback_file(fpath, previous_content if existed else None)
|
|
182
|
+
return f"Error: verification failed after writing {path}; change rolled back"
|
|
183
|
+
undo_recorded = record_file_change(
|
|
184
|
+
cwd,
|
|
185
|
+
fpath,
|
|
186
|
+
previous_content if existed else None,
|
|
187
|
+
)
|
|
188
|
+
if existed and not undo_recorded:
|
|
189
|
+
console.print(
|
|
190
|
+
"[yellow] Undo backup skipped for sensitive or oversized file.[/yellow]"
|
|
191
|
+
)
|
|
192
|
+
size = len(file_content)
|
|
193
|
+
console.print(f"[green] Created:[/green] {fpath} ({size} chars)")
|
|
194
|
+
return f"File created: {path} ({size} chars)"
|
|
195
|
+
|
|
196
|
+
elif tag == "edit_file":
|
|
197
|
+
path = attrs.get("path", "")
|
|
198
|
+
try:
|
|
199
|
+
fpath = resolve_project_path(cwd, path)
|
|
200
|
+
except FileError as e:
|
|
201
|
+
return f"Error: {e}"
|
|
202
|
+
if not fpath.exists():
|
|
203
|
+
return f"Error: file not found: {path}"
|
|
204
|
+
if not fpath.is_file():
|
|
205
|
+
return f"Error: not a file: {path}"
|
|
206
|
+
search_m = re.search(r'<search>(.*?)</search>', content, re.DOTALL)
|
|
207
|
+
replace_m = re.search(r'<replace>(.*?)</replace>', content, re.DOTALL)
|
|
208
|
+
if not search_m or not replace_m:
|
|
209
|
+
return "Error: edit_file needs <search>...</search> and <replace>...</replace> tags"
|
|
210
|
+
search_text = html.unescape(search_m.group(1).strip('\n'))
|
|
211
|
+
replace_text = html.unescape(replace_m.group(1).strip('\n'))
|
|
212
|
+
old_content = fpath.read_text(encoding="utf-8", errors="ignore")
|
|
213
|
+
if search_text not in old_content:
|
|
214
|
+
return f"Error: search text not found in {path}. Use read_file first to check exact content."
|
|
215
|
+
new_content = old_content.replace(search_text, replace_text, 1)
|
|
216
|
+
console.print(f"[yellow] Editing:[/yellow] {path}")
|
|
217
|
+
_show_diff(old_content, new_content, path)
|
|
218
|
+
try:
|
|
219
|
+
atomic_write_text(fpath, new_content, mode=0o644, lock=False)
|
|
220
|
+
except OSError as e:
|
|
221
|
+
return f"Error: could not edit {path}: {e}"
|
|
222
|
+
if not _verify_file_content(fpath, new_content):
|
|
223
|
+
_rollback_file(fpath, old_content)
|
|
224
|
+
return f"Error: verification failed after editing {path}; change rolled back"
|
|
225
|
+
if not record_file_change(cwd, fpath, old_content):
|
|
226
|
+
console.print(
|
|
227
|
+
"[yellow] Undo backup skipped for sensitive or oversized file.[/yellow]"
|
|
228
|
+
)
|
|
229
|
+
console.print(f"[green] Edited:[/green] {path}")
|
|
230
|
+
return f"File edited: {path}"
|
|
231
|
+
|
|
232
|
+
elif tag == "read_file":
|
|
233
|
+
path = attrs.get("path", "")
|
|
234
|
+
try:
|
|
235
|
+
fpath = resolve_project_path(cwd, path)
|
|
236
|
+
except FileError as e:
|
|
237
|
+
return f"Error: {e}"
|
|
238
|
+
if not fpath.exists():
|
|
239
|
+
matches = [f for f in os.listdir(cwd) if os.path.splitext(f)[0] == path or f == path]
|
|
240
|
+
if matches:
|
|
241
|
+
fpath = resolve_project_path(cwd, matches[0])
|
|
242
|
+
else:
|
|
243
|
+
return f"File not found: {path}"
|
|
244
|
+
if not fpath.is_file():
|
|
245
|
+
return f"Error: not a file: {path}"
|
|
246
|
+
text = fpath.read_text(encoding="utf-8", errors="ignore")
|
|
247
|
+
console.print(f"[dim] Read: {fpath.name} ({len(text)} chars)[/dim]")
|
|
248
|
+
return f"Contents of {fpath.name}:\n{text[:4000]}"
|
|
249
|
+
|
|
250
|
+
elif tag == "create_folder":
|
|
251
|
+
path = attrs.get("path", "")
|
|
252
|
+
try:
|
|
253
|
+
fpath = resolve_project_path(cwd, path)
|
|
254
|
+
except FileError as e:
|
|
255
|
+
return f"Error: {e}"
|
|
256
|
+
existed = fpath.exists()
|
|
257
|
+
try:
|
|
258
|
+
fpath.mkdir(parents=True, exist_ok=True)
|
|
259
|
+
except OSError as e:
|
|
260
|
+
return f"Error: could not create folder {path}: {e}"
|
|
261
|
+
if not fpath.is_dir():
|
|
262
|
+
return f"Error: verification failed after creating folder {path}"
|
|
263
|
+
if not existed:
|
|
264
|
+
record_created_folder(cwd, fpath)
|
|
265
|
+
console.print(f"[green] Folder:[/green] {fpath}")
|
|
266
|
+
return f"Folder created: {path}"
|
|
267
|
+
|
|
268
|
+
elif tag == "rename_path":
|
|
269
|
+
source = attrs.get("from", "")
|
|
270
|
+
destination = attrs.get("to", "")
|
|
271
|
+
if not source or not destination:
|
|
272
|
+
return "Error: rename_path needs from= and to= attributes"
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
source_path = resolve_project_path(cwd, source)
|
|
276
|
+
destination_path = resolve_project_path(cwd, destination)
|
|
277
|
+
except FileError as e:
|
|
278
|
+
return f"Error: {e}"
|
|
279
|
+
if not source_path.exists():
|
|
280
|
+
return f"Error: path not found: {source}"
|
|
281
|
+
if destination_path.exists():
|
|
282
|
+
return f"Error: destination already exists: {destination}"
|
|
283
|
+
|
|
284
|
+
source_was_dir = source_path.is_dir()
|
|
285
|
+
try:
|
|
286
|
+
destination_path.parent.mkdir(parents=True, exist_ok=True)
|
|
287
|
+
source_path.rename(destination_path)
|
|
288
|
+
except OSError as e:
|
|
289
|
+
return f"Error: could not rename {source} to {destination}: {e}"
|
|
290
|
+
rename_verified = (
|
|
291
|
+
not source_path.exists()
|
|
292
|
+
and destination_path.exists()
|
|
293
|
+
and destination_path.is_dir() == source_was_dir
|
|
294
|
+
)
|
|
295
|
+
if not rename_verified:
|
|
296
|
+
try:
|
|
297
|
+
if destination_path.exists() and not source_path.exists():
|
|
298
|
+
destination_path.rename(source_path)
|
|
299
|
+
except OSError:
|
|
300
|
+
pass
|
|
301
|
+
return (
|
|
302
|
+
f"Error: verification failed after renaming {source} to "
|
|
303
|
+
f"{destination}; rollback attempted"
|
|
304
|
+
)
|
|
305
|
+
record_rename(cwd, source_path, destination_path)
|
|
306
|
+
console.print(f"[green] Renamed:[/green] {source} -> {destination}")
|
|
307
|
+
return f"Path renamed: {source} -> {destination}"
|
|
308
|
+
|
|
309
|
+
elif tag == "list_files":
|
|
310
|
+
path = attrs.get("path", ".")
|
|
311
|
+
try:
|
|
312
|
+
fpath = resolve_project_path(cwd, path, allow_root=True)
|
|
313
|
+
except FileError as e:
|
|
314
|
+
return f"Error: {e}"
|
|
315
|
+
if not fpath.exists():
|
|
316
|
+
return f"Directory not found: {path}"
|
|
317
|
+
if not fpath.is_dir():
|
|
318
|
+
return f"Error: not a directory: {path}"
|
|
319
|
+
items = sorted(os.listdir(fpath))
|
|
320
|
+
result = "\n".join(
|
|
321
|
+
f"{'[dir] ' if (fpath / i).is_dir() else ''}{i}" for i in items
|
|
322
|
+
)
|
|
323
|
+
console.print(f"[dim] Listed: {fpath}[/dim]")
|
|
324
|
+
return f"Files in {path}:\n{result}"
|
|
325
|
+
|
|
326
|
+
elif tag == "run_command":
|
|
327
|
+
cmd = content.strip()
|
|
328
|
+
if not cmd:
|
|
329
|
+
return "Error: no command given"
|
|
330
|
+
console.print(f"[yellow] Run:[/yellow] {cmd}")
|
|
331
|
+
result = run_direct(
|
|
332
|
+
cmd,
|
|
333
|
+
cwd=cwd,
|
|
334
|
+
timeout=30,
|
|
335
|
+
approval=lambda reason: Confirm.ask(
|
|
336
|
+
f" Approval required ({reason}). Run it?",
|
|
337
|
+
default=False,
|
|
338
|
+
),
|
|
339
|
+
)
|
|
340
|
+
if result.blocked_reason:
|
|
341
|
+
return f"Error: command blocked ({result.blocked_reason})"
|
|
342
|
+
if result.cancelled:
|
|
343
|
+
return "Command cancelled"
|
|
344
|
+
if result.returncode != 0:
|
|
345
|
+
details = result.output[:1800] if result.output else "no output"
|
|
346
|
+
return f"Error: command failed with exit code {result.returncode}: {details}"
|
|
347
|
+
return result.output[:2000] if result.output else "Done (no output)"
|
|
348
|
+
|
|
349
|
+
elif tag == "mcp_call":
|
|
350
|
+
server = attrs.get("server", "")
|
|
351
|
+
tool = attrs.get("tool", "")
|
|
352
|
+
if not server or not tool:
|
|
353
|
+
return "Error: mcp_call needs server= and tool= attributes"
|
|
354
|
+
try:
|
|
355
|
+
arguments = json.loads(content) if content.strip() else {}
|
|
356
|
+
except Exception:
|
|
357
|
+
arguments = {}
|
|
358
|
+
if not Confirm.ask(
|
|
359
|
+
f" Allow MCP tool {server}/{tool} with arguments {arguments}?",
|
|
360
|
+
default=False,
|
|
361
|
+
):
|
|
362
|
+
return "MCP tool call cancelled"
|
|
363
|
+
console.print(f"[magenta] MCP:[/magenta] {server}/{tool}")
|
|
364
|
+
try:
|
|
365
|
+
from ..mcp.client import call_mcp_tool
|
|
366
|
+
result = call_mcp_tool(server, tool, arguments)
|
|
367
|
+
except Exception as e:
|
|
368
|
+
result = f"MCP error: {e}"
|
|
369
|
+
raise_if_cancelled()
|
|
370
|
+
return result
|
|
371
|
+
|
|
372
|
+
elif tag == "plugin_call":
|
|
373
|
+
plugin_name = attrs.get("plugin", "")
|
|
374
|
+
tool_name = attrs.get("tool", "")
|
|
375
|
+
if not plugin_name or not tool_name:
|
|
376
|
+
return "Error: plugin_call needs plugin= and tool= attributes"
|
|
377
|
+
try:
|
|
378
|
+
arguments = json.loads(content) if content.strip() else {}
|
|
379
|
+
except Exception:
|
|
380
|
+
arguments = {}
|
|
381
|
+
from ..plugins.loader import get_manifest
|
|
382
|
+
|
|
383
|
+
manifest = get_manifest(plugin_name) or {}
|
|
384
|
+
permissions = ", ".join(manifest.get("permissions", [])) or "none"
|
|
385
|
+
if not Confirm.ask(
|
|
386
|
+
f" Allow trusted plugin tool {plugin_name}/{tool_name} "
|
|
387
|
+
f"(permissions: {permissions})?",
|
|
388
|
+
default=False,
|
|
389
|
+
):
|
|
390
|
+
return "Plugin tool call cancelled"
|
|
391
|
+
console.print(f"[blue] Plugin:[/blue] {plugin_name}/{tool_name}")
|
|
392
|
+
try:
|
|
393
|
+
from ..plugins.loader import get_loaded
|
|
394
|
+
plugins = get_loaded()
|
|
395
|
+
if plugin_name not in plugins:
|
|
396
|
+
return f"Plugin '{plugin_name}' not loaded. Run: zai plugin list"
|
|
397
|
+
plugin = plugins[plugin_name]
|
|
398
|
+
tools = plugin.get_tools()
|
|
399
|
+
if tool_name not in tools:
|
|
400
|
+
return f"Tool '{tool_name}' not found in plugin '{plugin_name}'"
|
|
401
|
+
result = tools[tool_name](**arguments)
|
|
402
|
+
raise_if_cancelled()
|
|
403
|
+
return str(result)
|
|
404
|
+
except OperationCancelled:
|
|
405
|
+
raise
|
|
406
|
+
except Exception as e:
|
|
407
|
+
return f"Plugin error: {e}"
|
|
408
|
+
|
|
409
|
+
return f"Unknown tool: {tag}"
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
TOOL_PATTERN = re.compile(
|
|
413
|
+
r'<(write_file|read_file|create_folder|rename_path|list_files|run_command|edit_file|mcp_call|plugin_call)([^>]*)(?:>(.*?)</\1>|/>)',
|
|
414
|
+
re.DOTALL,
|
|
415
|
+
)
|
|
416
|
+
STRUCTURED_TOOL_PATTERN = re.compile(
|
|
417
|
+
r"<tool_call>\s*(.*?)\s*</tool_call>",
|
|
418
|
+
re.DOTALL,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _structured_to_legacy(name: str, arguments: dict) -> tuple[dict, str]:
|
|
423
|
+
"""Adapt validated JSON arguments to the existing execution implementation."""
|
|
424
|
+
if name == "write_file":
|
|
425
|
+
return {"path": arguments["path"]}, arguments["content"]
|
|
426
|
+
if name == "edit_file":
|
|
427
|
+
content = (
|
|
428
|
+
f"<search>{html.escape(arguments['search'])}</search>"
|
|
429
|
+
f"<replace>{html.escape(arguments['replace'])}</replace>"
|
|
430
|
+
)
|
|
431
|
+
return {"path": arguments["path"]}, content
|
|
432
|
+
if name in {"read_file", "create_folder", "list_files"}:
|
|
433
|
+
return {"path": arguments["path"]}, ""
|
|
434
|
+
if name == "rename_path":
|
|
435
|
+
return {
|
|
436
|
+
"from": arguments["source"],
|
|
437
|
+
"to": arguments["destination"],
|
|
438
|
+
}, ""
|
|
439
|
+
if name == "run_command":
|
|
440
|
+
return {}, arguments["command"]
|
|
441
|
+
if name == "mcp_call":
|
|
442
|
+
return {
|
|
443
|
+
"server": arguments["server"],
|
|
444
|
+
"tool": arguments["tool"],
|
|
445
|
+
}, json.dumps(arguments["arguments"])
|
|
446
|
+
if name == "plugin_call":
|
|
447
|
+
return {
|
|
448
|
+
"plugin": arguments["plugin"],
|
|
449
|
+
"tool": arguments["tool"],
|
|
450
|
+
}, json.dumps(arguments["arguments"])
|
|
451
|
+
return {}, ""
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _load_system(cwd: str) -> str:
|
|
455
|
+
"""Build system prompt: CLAUDE.md (project) + global + AGENT_SYSTEM + MCP tools."""
|
|
456
|
+
parts = []
|
|
457
|
+
|
|
458
|
+
# Global user instructions
|
|
459
|
+
global_md = Path.home() / ".zai" / "CLAUDE.md"
|
|
460
|
+
if global_md.exists():
|
|
461
|
+
txt = global_md.read_text(encoding="utf-8", errors="ignore").strip()
|
|
462
|
+
if txt:
|
|
463
|
+
parts.append(f"USER INSTRUCTIONS (global ~/.zai/CLAUDE.md):\n{txt}")
|
|
464
|
+
|
|
465
|
+
# Project instructions
|
|
466
|
+
project_md = Path(cwd) / "CLAUDE.md"
|
|
467
|
+
if project_md.exists():
|
|
468
|
+
txt = project_md.read_text(encoding="utf-8", errors="ignore").strip()
|
|
469
|
+
if txt:
|
|
470
|
+
parts.append(f"PROJECT INSTRUCTIONS (CLAUDE.md):\n{txt}")
|
|
471
|
+
|
|
472
|
+
parts.append(AGENT_SYSTEM)
|
|
473
|
+
|
|
474
|
+
# Active MCP tools
|
|
475
|
+
try:
|
|
476
|
+
from ..mcp.client import get_all_tools
|
|
477
|
+
all_tools = get_all_tools()
|
|
478
|
+
if all_tools:
|
|
479
|
+
mcp_lines = ["\nMCP TOOLS AVAILABLE through mcp_call:"]
|
|
480
|
+
for server, tools in all_tools.items():
|
|
481
|
+
for t in tools:
|
|
482
|
+
desc = t.get("description", "")[:80]
|
|
483
|
+
props = list(t.get("inputSchema", {}).get("properties", {}).keys())
|
|
484
|
+
mcp_lines.append(
|
|
485
|
+
f'- {server}/{t["name"]}: {desc} '
|
|
486
|
+
f'(arguments: {", ".join(props[:4])})'
|
|
487
|
+
)
|
|
488
|
+
parts.append("\n".join(mcp_lines))
|
|
489
|
+
except Exception:
|
|
490
|
+
pass
|
|
491
|
+
|
|
492
|
+
# Plugin tools
|
|
493
|
+
try:
|
|
494
|
+
from ..plugins.loader import get_agent_descriptions
|
|
495
|
+
plugin_desc = get_agent_descriptions()
|
|
496
|
+
if plugin_desc:
|
|
497
|
+
parts.append(plugin_desc)
|
|
498
|
+
except Exception:
|
|
499
|
+
pass
|
|
500
|
+
|
|
501
|
+
return "\n\n---\n\n".join(parts)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def parse_and_execute(response: str, cwd: str) -> tuple[str, list[str]]:
|
|
505
|
+
"""Find tool calls in AI response, execute them, return (clean_text, results)."""
|
|
506
|
+
from pydantic import ValidationError
|
|
507
|
+
from .tool_schema import format_validation_error, validate_tool_call
|
|
508
|
+
|
|
509
|
+
results = []
|
|
510
|
+
clean = response
|
|
511
|
+
|
|
512
|
+
for match in STRUCTURED_TOOL_PATTERN.finditer(response):
|
|
513
|
+
raw_call = match.group(1).strip()
|
|
514
|
+
try:
|
|
515
|
+
data = json.loads(raw_call)
|
|
516
|
+
name, arguments = validate_tool_call(data)
|
|
517
|
+
attrs, content = _structured_to_legacy(name, arguments)
|
|
518
|
+
result = _execute_tool(name, attrs, content, cwd)
|
|
519
|
+
results.append((name, result))
|
|
520
|
+
except json.JSONDecodeError as error:
|
|
521
|
+
results.append((
|
|
522
|
+
"tool_call",
|
|
523
|
+
f"Error: invalid tool JSON at position {error.pos}: {error.msg}",
|
|
524
|
+
))
|
|
525
|
+
except ValidationError as error:
|
|
526
|
+
results.append((
|
|
527
|
+
"tool_call",
|
|
528
|
+
f"Error: invalid tool call: {format_validation_error(error)}",
|
|
529
|
+
))
|
|
530
|
+
clean = clean.replace(match.group(0), "")
|
|
531
|
+
|
|
532
|
+
# Legacy per-tool XML remains accepted during migration.
|
|
533
|
+
for m in TOOL_PATTERN.finditer(response):
|
|
534
|
+
tag = m.group(1)
|
|
535
|
+
attrs = _parse_attrs(m.group(2))
|
|
536
|
+
content = (m.group(3) or "").strip()
|
|
537
|
+
result = _execute_tool(tag, attrs, content, cwd)
|
|
538
|
+
results.append((tag, result))
|
|
539
|
+
clean = clean.replace(m.group(0), "")
|
|
540
|
+
|
|
541
|
+
return clean.strip(), results
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def execute_native_tool_calls(tool_calls: list, cwd: str) -> list[tuple[str, str, str]]:
|
|
545
|
+
"""Validate and execute provider-native tool calls."""
|
|
546
|
+
from pydantic import ValidationError
|
|
547
|
+
from .tool_schema import format_validation_error, validate_tool_call
|
|
548
|
+
|
|
549
|
+
results = []
|
|
550
|
+
for call in tool_calls:
|
|
551
|
+
call_id = getattr(call, "id", "") or "tool-call"
|
|
552
|
+
raw_name = getattr(call, "name", "")
|
|
553
|
+
raw_arguments = getattr(call, "arguments", {})
|
|
554
|
+
try:
|
|
555
|
+
name, arguments = validate_tool_call({
|
|
556
|
+
"name": raw_name,
|
|
557
|
+
"arguments": raw_arguments,
|
|
558
|
+
})
|
|
559
|
+
attrs, content = _structured_to_legacy(name, arguments)
|
|
560
|
+
result = _execute_tool(name, attrs, content, cwd)
|
|
561
|
+
results.append((call_id, name, result))
|
|
562
|
+
except ValidationError as error:
|
|
563
|
+
result = f"Error: invalid tool call: {format_validation_error(error)}"
|
|
564
|
+
results.append((call_id, raw_name or "tool_call", result))
|
|
565
|
+
return results
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def plan_agent(user_message: str, history: list, cwd: str, preferred: str = None) -> str:
|
|
569
|
+
"""Generate a plan without executing any tools."""
|
|
570
|
+
from .fallback import stream_with_fallback
|
|
571
|
+
from ..providers.base import Message
|
|
572
|
+
|
|
573
|
+
messages = list(history) + [Message(role="user", content=user_message)]
|
|
574
|
+
try:
|
|
575
|
+
content, model = stream_with_fallback(messages, system=PLAN_SYSTEM, preferred=preferred)
|
|
576
|
+
console.print(f"[dim]── {model} ──[/dim]")
|
|
577
|
+
return content
|
|
578
|
+
except Exception as e:
|
|
579
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
580
|
+
return ""
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def undo_last(cwd: str) -> str:
|
|
584
|
+
"""Restore the latest recorded filesystem action, including after restart."""
|
|
585
|
+
from .undo import undo_last_action
|
|
586
|
+
|
|
587
|
+
result = undo_last_action(cwd)
|
|
588
|
+
if not result.startswith(("Nothing", "Cannot")):
|
|
589
|
+
console.print(f"[green] {result}[/green]")
|
|
590
|
+
return result
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def run_agent(user_message: str, history: list, cwd: str, preferred: str = None) -> str:
|
|
594
|
+
with operation():
|
|
595
|
+
return _run_agent(user_message, history, cwd, preferred)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _run_agent(user_message: str, history: list, cwd: str, preferred: str = None) -> str:
|
|
599
|
+
"""
|
|
600
|
+
Full agentic loop: send → AI responds with tool calls → execute → feed back → repeat.
|
|
601
|
+
Returns final response text.
|
|
602
|
+
"""
|
|
603
|
+
global _session_tokens
|
|
604
|
+
from .fallback import chat_with_fallback
|
|
605
|
+
from ..providers.base import Message
|
|
606
|
+
from .tool_schema import get_tool_definitions
|
|
607
|
+
from .context import ContextManager, bound_tool_result
|
|
608
|
+
|
|
609
|
+
system = _load_system(cwd)
|
|
610
|
+
context = ContextManager(model=preferred)
|
|
611
|
+
context.replace_messages(list(history))
|
|
612
|
+
context.add("user", user_message)
|
|
613
|
+
messages = context.get_messages()
|
|
614
|
+
MAX_TURNS = 6
|
|
615
|
+
clean_text = ""
|
|
616
|
+
used_model = preferred or "ai"
|
|
617
|
+
|
|
618
|
+
MUTATION_TAGS = {"write_file", "edit_file", "create_folder", "rename_path"}
|
|
619
|
+
|
|
620
|
+
for turn in range(MAX_TURNS):
|
|
621
|
+
raise_if_cancelled()
|
|
622
|
+
try:
|
|
623
|
+
with console.status("[cyan]Thinking...[/cyan]"):
|
|
624
|
+
response, used_model = chat_with_fallback(
|
|
625
|
+
messages,
|
|
626
|
+
system=system,
|
|
627
|
+
preferred=preferred,
|
|
628
|
+
tools=get_tool_definitions(),
|
|
629
|
+
)
|
|
630
|
+
content = response.content
|
|
631
|
+
except OperationCancelled:
|
|
632
|
+
raise
|
|
633
|
+
except Exception as e:
|
|
634
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
635
|
+
return ""
|
|
636
|
+
|
|
637
|
+
_session_tokens += response.tokens_used
|
|
638
|
+
|
|
639
|
+
# Strip tool tags BEFORE showing anything — never show raw XML
|
|
640
|
+
if response.tool_calls:
|
|
641
|
+
clean_text = content.strip()
|
|
642
|
+
native_results = execute_native_tool_calls(response.tool_calls, cwd)
|
|
643
|
+
tool_results = [
|
|
644
|
+
(name, result) for _, name, result in native_results
|
|
645
|
+
]
|
|
646
|
+
else:
|
|
647
|
+
clean_text, tool_results = parse_and_execute(content, cwd)
|
|
648
|
+
raise_if_cancelled()
|
|
649
|
+
|
|
650
|
+
if not tool_results:
|
|
651
|
+
# Pure text response — show it and stop
|
|
652
|
+
if clean_text.strip():
|
|
653
|
+
console.print(clean_text)
|
|
654
|
+
console.print(f"[dim]── {format_model_selection(used_model)} ──[/dim]")
|
|
655
|
+
return clean_text
|
|
656
|
+
|
|
657
|
+
# Tools ran — check what happened
|
|
658
|
+
errors = [res for tag, res in tool_results if res.startswith("Error") or "error" in res.lower()[:30]]
|
|
659
|
+
has_mutation = any(tag in MUTATION_TAGS for tag, _ in tool_results)
|
|
660
|
+
has_result_tool = any(
|
|
661
|
+
tag not in MUTATION_TAGS
|
|
662
|
+
for tag, _ in tool_results
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
if has_mutation and not has_result_tool and not errors:
|
|
666
|
+
# Pure filesystem mutations already showed their verified outcome inline.
|
|
667
|
+
console.print(f"[dim]── {format_model_selection(used_model)} ──[/dim]")
|
|
668
|
+
return clean_text or "\n".join(
|
|
669
|
+
result for tag, result in tool_results if tag in MUTATION_TAGS
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
# Need another round: read-only tools (AI needs file content to act) or errors
|
|
673
|
+
tool_summary = "\n".join(f"[{tag}] {res}" for tag, res in tool_results)
|
|
674
|
+
if response.tool_calls:
|
|
675
|
+
messages.append(Message(
|
|
676
|
+
role="assistant",
|
|
677
|
+
content=content,
|
|
678
|
+
tool_calls=response.tool_calls,
|
|
679
|
+
))
|
|
680
|
+
for call_id, name, result in native_results:
|
|
681
|
+
messages.append(Message(
|
|
682
|
+
role="tool",
|
|
683
|
+
content=bound_tool_result(result),
|
|
684
|
+
tool_call_id=call_id,
|
|
685
|
+
tool_name=name,
|
|
686
|
+
))
|
|
687
|
+
else:
|
|
688
|
+
messages.append(Message(role="assistant", content=content))
|
|
689
|
+
if errors:
|
|
690
|
+
messages.append(Message(
|
|
691
|
+
role="user",
|
|
692
|
+
content=f"Tool errors:\n{tool_summary}\nFix and retry.",
|
|
693
|
+
))
|
|
694
|
+
else:
|
|
695
|
+
messages.append(Message(
|
|
696
|
+
role="user",
|
|
697
|
+
content=f"Tool results:\n{tool_summary}",
|
|
698
|
+
))
|
|
699
|
+
|
|
700
|
+
console.print(f"[dim]── {format_model_selection(used_model)} ──[/dim]")
|
|
701
|
+
return clean_text
|