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.
Files changed (68) hide show
  1. zai/__init__.py +1 -0
  2. zai/__main__.py +4 -0
  3. zai/cli/__init__.py +1 -0
  4. zai/cli/common.py +16 -0
  5. zai/cli/integrations.py +319 -0
  6. zai/cli/interactive.py +518 -0
  7. zai/cli/settings.py +436 -0
  8. zai/cli/utilities.py +227 -0
  9. zai/cli/workflows.py +137 -0
  10. zai/commands/commit.md +24 -0
  11. zai/commands/explain.md +17 -0
  12. zai/commands/feature.md +34 -0
  13. zai/commands/fix.md +14 -0
  14. zai/commands/review.md +22 -0
  15. zai/config.py +307 -0
  16. zai/core/__init__.py +0 -0
  17. zai/core/agent.py +701 -0
  18. zai/core/cancellation.py +67 -0
  19. zai/core/commands.py +85 -0
  20. zai/core/context.py +299 -0
  21. zai/core/errors.py +125 -0
  22. zai/core/fallback.py +171 -0
  23. zai/core/hooks.py +115 -0
  24. zai/core/memory.py +57 -0
  25. zai/core/process.py +204 -0
  26. zai/core/repomap.py +381 -0
  27. zai/core/runtime.py +29 -0
  28. zai/core/security.py +33 -0
  29. zai/core/session.py +425 -0
  30. zai/core/storage.py +193 -0
  31. zai/core/streaming.py +157 -0
  32. zai/core/tool_schema.py +133 -0
  33. zai/core/undo.py +443 -0
  34. zai/core/watch.py +80 -0
  35. zai/main.py +210 -0
  36. zai/mcp/__init__.py +0 -0
  37. zai/mcp/client.py +431 -0
  38. zai/mcp/manager.py +118 -0
  39. zai/plugins/__init__.py +2 -0
  40. zai/plugins/base.py +49 -0
  41. zai/plugins/loader.py +404 -0
  42. zai/providers/__init__.py +22 -0
  43. zai/providers/anthropic.py +131 -0
  44. zai/providers/base.py +67 -0
  45. zai/providers/cerebras.py +57 -0
  46. zai/providers/gemini.py +119 -0
  47. zai/providers/groq.py +116 -0
  48. zai/providers/ollama.py +62 -0
  49. zai/providers/openai.py +124 -0
  50. zai/providers/openrouter.py +63 -0
  51. zai/providers/qwen.py +47 -0
  52. zai/skills/__init__.py +0 -0
  53. zai/skills/registry.py +52 -0
  54. zai/tools/__init__.py +0 -0
  55. zai/tools/browser.py +224 -0
  56. zai/tools/code_runner.py +49 -0
  57. zai/tools/files.py +53 -0
  58. zai/tools/git.py +38 -0
  59. zai/tools/search.py +157 -0
  60. zai/tools/vision.py +128 -0
  61. zai/ui/__init__.py +0 -0
  62. zai/ui/input.py +199 -0
  63. zai_cli-0.1.0.dist-info/METADATA +722 -0
  64. zai_cli-0.1.0.dist-info/RECORD +68 -0
  65. zai_cli-0.1.0.dist-info/WHEEL +5 -0
  66. zai_cli-0.1.0.dist-info/entry_points.txt +2 -0
  67. zai_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  68. 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