cortex-llm 1.0.8__py3-none-any.whl → 1.0.10__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.
- cortex/__init__.py +1 -1
- cortex/inference_engine.py +48 -8
- cortex/tools/__init__.py +5 -0
- cortex/tools/errors.py +9 -0
- cortex/tools/fs_ops.py +135 -0
- cortex/tools/protocol.py +76 -0
- cortex/tools/search.py +70 -0
- cortex/tools/tool_runner.py +144 -0
- cortex/ui/cli.py +231 -124
- cortex/ui/markdown_render.py +9 -0
- {cortex_llm-1.0.8.dist-info → cortex_llm-1.0.10.dist-info}/METADATA +5 -1
- {cortex_llm-1.0.8.dist-info → cortex_llm-1.0.10.dist-info}/RECORD +16 -10
- {cortex_llm-1.0.8.dist-info → cortex_llm-1.0.10.dist-info}/WHEEL +0 -0
- {cortex_llm-1.0.8.dist-info → cortex_llm-1.0.10.dist-info}/entry_points.txt +0 -0
- {cortex_llm-1.0.8.dist-info → cortex_llm-1.0.10.dist-info}/licenses/LICENSE +0 -0
- {cortex_llm-1.0.8.dist-info → cortex_llm-1.0.10.dist-info}/top_level.txt +0 -0
cortex/__init__.py
CHANGED
|
@@ -5,7 +5,7 @@ A high-performance terminal interface for running Hugging Face LLMs locally
|
|
|
5
5
|
with exclusive GPU acceleration via Metal Performance Shaders (MPS) and MLX.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
__version__ = "1.0.
|
|
8
|
+
__version__ = "1.0.10"
|
|
9
9
|
__author__ = "Cortex Development Team"
|
|
10
10
|
__license__ = "MIT"
|
|
11
11
|
|
cortex/inference_engine.py
CHANGED
|
@@ -243,6 +243,33 @@ class InferenceEngine:
|
|
|
243
243
|
tokens_generated = 0
|
|
244
244
|
first_token_time = None
|
|
245
245
|
last_metrics_update = time.time()
|
|
246
|
+
stream_total_text = ""
|
|
247
|
+
stream_cumulative = False
|
|
248
|
+
|
|
249
|
+
def normalize_stream_chunk(chunk: Any) -> str:
|
|
250
|
+
"""Normalize streaming output to delta chunks when backend yields cumulative text."""
|
|
251
|
+
nonlocal stream_total_text, stream_cumulative
|
|
252
|
+
if chunk is None:
|
|
253
|
+
return ""
|
|
254
|
+
if not isinstance(chunk, str):
|
|
255
|
+
chunk = str(chunk)
|
|
256
|
+
|
|
257
|
+
if stream_cumulative:
|
|
258
|
+
if chunk.startswith(stream_total_text):
|
|
259
|
+
delta = chunk[len(stream_total_text):]
|
|
260
|
+
stream_total_text = chunk
|
|
261
|
+
return delta
|
|
262
|
+
stream_total_text += chunk
|
|
263
|
+
return chunk
|
|
264
|
+
|
|
265
|
+
if stream_total_text and len(chunk) > len(stream_total_text) and chunk.startswith(stream_total_text):
|
|
266
|
+
stream_cumulative = True
|
|
267
|
+
delta = chunk[len(stream_total_text):]
|
|
268
|
+
stream_total_text = chunk
|
|
269
|
+
return delta
|
|
270
|
+
|
|
271
|
+
stream_total_text += chunk
|
|
272
|
+
return chunk
|
|
246
273
|
|
|
247
274
|
try:
|
|
248
275
|
# Use MLX accelerator's optimized generation if available
|
|
@@ -262,10 +289,14 @@ class InferenceEngine:
|
|
|
262
289
|
if self._cancel_event.is_set():
|
|
263
290
|
self.status = InferenceStatus.CANCELLED
|
|
264
291
|
break
|
|
265
|
-
|
|
292
|
+
|
|
293
|
+
delta = normalize_stream_chunk(token) if request.stream else str(token)
|
|
294
|
+
if not delta:
|
|
295
|
+
continue
|
|
296
|
+
|
|
266
297
|
if first_token_time is None:
|
|
267
298
|
first_token_time = time.time() - start_time
|
|
268
|
-
|
|
299
|
+
|
|
269
300
|
tokens_generated += 1
|
|
270
301
|
|
|
271
302
|
# Update metrics less frequently
|
|
@@ -284,13 +315,18 @@ class InferenceEngine:
|
|
|
284
315
|
last_metrics_update = current_time
|
|
285
316
|
|
|
286
317
|
# Token is already a string from generate_optimized
|
|
287
|
-
yield
|
|
318
|
+
yield delta
|
|
288
319
|
|
|
289
320
|
if any(stop in token for stop in request.stop_sequences):
|
|
290
321
|
break
|
|
291
322
|
elif mlx_generate:
|
|
292
323
|
# Fallback to standard MLX generation
|
|
293
|
-
|
|
324
|
+
if request.stream and mlx_stream_generate:
|
|
325
|
+
logger.info("Using MLX streaming generation")
|
|
326
|
+
generate_fn = mlx_stream_generate
|
|
327
|
+
else:
|
|
328
|
+
logger.info("Using standard MLX generation")
|
|
329
|
+
generate_fn = mlx_generate
|
|
294
330
|
|
|
295
331
|
# Import sample_utils for creating sampler
|
|
296
332
|
try:
|
|
@@ -314,7 +350,7 @@ class InferenceEngine:
|
|
|
314
350
|
if request.seed is not None and request.seed >= 0:
|
|
315
351
|
mx.random.seed(request.seed)
|
|
316
352
|
|
|
317
|
-
for response in
|
|
353
|
+
for response in generate_fn(
|
|
318
354
|
model,
|
|
319
355
|
tokenizer,
|
|
320
356
|
**generation_kwargs
|
|
@@ -328,10 +364,14 @@ class InferenceEngine:
|
|
|
328
364
|
token = response.text
|
|
329
365
|
else:
|
|
330
366
|
token = str(response)
|
|
331
|
-
|
|
367
|
+
|
|
368
|
+
delta = normalize_stream_chunk(token) if request.stream else token
|
|
369
|
+
if request.stream and not delta:
|
|
370
|
+
continue
|
|
371
|
+
|
|
332
372
|
if first_token_time is None:
|
|
333
373
|
first_token_time = time.time() - start_time
|
|
334
|
-
|
|
374
|
+
|
|
335
375
|
tokens_generated += 1
|
|
336
376
|
|
|
337
377
|
# Update metrics less frequently to reduce overhead
|
|
@@ -352,7 +392,7 @@ class InferenceEngine:
|
|
|
352
392
|
)
|
|
353
393
|
last_metrics_update = current_time
|
|
354
394
|
|
|
355
|
-
yield
|
|
395
|
+
yield delta
|
|
356
396
|
|
|
357
397
|
if any(stop in token for stop in request.stop_sequences):
|
|
358
398
|
break
|
cortex/tools/__init__.py
ADDED
cortex/tools/errors.py
ADDED
cortex/tools/fs_ops.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Filesystem operations scoped to a repo root."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, List, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
from cortex.tools.errors import ToolError, ValidationError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RepoFS:
|
|
15
|
+
"""Filesystem helper constrained to a single repo root."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, root: Path) -> None:
|
|
18
|
+
self.root = Path(root).expanduser().resolve()
|
|
19
|
+
|
|
20
|
+
def resolve_path(self, path: str) -> Path:
|
|
21
|
+
if not path or not isinstance(path, str):
|
|
22
|
+
raise ValidationError("path must be a non-empty string")
|
|
23
|
+
raw = Path(path).expanduser()
|
|
24
|
+
resolved = raw.resolve() if raw.is_absolute() else (self.root / raw).resolve()
|
|
25
|
+
if not resolved.is_relative_to(self.root):
|
|
26
|
+
raise ValidationError(f"path escapes repo root ({self.root}); use a relative path like '.'")
|
|
27
|
+
return resolved
|
|
28
|
+
|
|
29
|
+
def list_dir(self, path: str = ".", recursive: bool = False, max_depth: int = 2, max_entries: int = 200) -> Dict[str, List[str]]:
|
|
30
|
+
target = self.resolve_path(path)
|
|
31
|
+
if not target.is_dir():
|
|
32
|
+
raise ValidationError("path is not a directory")
|
|
33
|
+
entries: List[str] = []
|
|
34
|
+
if not recursive:
|
|
35
|
+
for item in sorted(target.iterdir()):
|
|
36
|
+
rel = item.relative_to(self.root)
|
|
37
|
+
suffix = "/" if item.is_dir() else ""
|
|
38
|
+
entries.append(f"{rel}{suffix}")
|
|
39
|
+
if len(entries) >= max_entries:
|
|
40
|
+
break
|
|
41
|
+
return {"entries": entries}
|
|
42
|
+
|
|
43
|
+
base_depth = len(target.relative_to(self.root).parts)
|
|
44
|
+
for dirpath, dirnames, filenames in os.walk(target):
|
|
45
|
+
depth = len(Path(dirpath).relative_to(self.root).parts) - base_depth
|
|
46
|
+
if depth > max_depth:
|
|
47
|
+
dirnames[:] = []
|
|
48
|
+
continue
|
|
49
|
+
for name in sorted(dirnames):
|
|
50
|
+
rel = (Path(dirpath) / name).relative_to(self.root)
|
|
51
|
+
entries.append(f"{rel}/")
|
|
52
|
+
if len(entries) >= max_entries:
|
|
53
|
+
return {"entries": entries}
|
|
54
|
+
for name in sorted(filenames):
|
|
55
|
+
rel = (Path(dirpath) / name).relative_to(self.root)
|
|
56
|
+
entries.append(str(rel))
|
|
57
|
+
if len(entries) >= max_entries:
|
|
58
|
+
return {"entries": entries}
|
|
59
|
+
return {"entries": entries}
|
|
60
|
+
|
|
61
|
+
def read_text(self, path: str, start_line: int = 1, end_line: Optional[int] = None, max_bytes: int = 2_000_000) -> Dict[str, object]:
|
|
62
|
+
target = self.resolve_path(path)
|
|
63
|
+
if not target.is_file():
|
|
64
|
+
raise ValidationError("path is not a file")
|
|
65
|
+
size = target.stat().st_size
|
|
66
|
+
if size > max_bytes and start_line == 1 and end_line is None:
|
|
67
|
+
raise ToolError("file too large; specify a line range")
|
|
68
|
+
if start_line < 1:
|
|
69
|
+
raise ValidationError("start_line must be >= 1")
|
|
70
|
+
if end_line is not None and end_line < start_line:
|
|
71
|
+
raise ValidationError("end_line must be >= start_line")
|
|
72
|
+
|
|
73
|
+
lines: List[str] = []
|
|
74
|
+
with target.open("r", encoding="utf-8") as handle:
|
|
75
|
+
for idx, line in enumerate(handle, start=1):
|
|
76
|
+
if idx < start_line:
|
|
77
|
+
continue
|
|
78
|
+
if end_line is not None and idx > end_line:
|
|
79
|
+
break
|
|
80
|
+
lines.append(line.rstrip("\n"))
|
|
81
|
+
content = "\n".join(lines)
|
|
82
|
+
return {"path": str(target.relative_to(self.root)), "content": content, "start_line": start_line, "end_line": end_line}
|
|
83
|
+
|
|
84
|
+
def read_full_text(self, path: str) -> str:
|
|
85
|
+
target = self.resolve_path(path)
|
|
86
|
+
if not target.is_file():
|
|
87
|
+
raise ValidationError("path is not a file")
|
|
88
|
+
try:
|
|
89
|
+
return target.read_text(encoding="utf-8")
|
|
90
|
+
except UnicodeDecodeError as e:
|
|
91
|
+
raise ToolError(f"file is not valid utf-8: {e}") from e
|
|
92
|
+
|
|
93
|
+
def write_text(self, path: str, content: str, expected_sha256: Optional[str] = None) -> Dict[str, object]:
|
|
94
|
+
target = self.resolve_path(path)
|
|
95
|
+
if not target.exists() or not target.is_file():
|
|
96
|
+
raise ValidationError("path does not exist or is not a file")
|
|
97
|
+
if expected_sha256:
|
|
98
|
+
current = self.read_full_text(path)
|
|
99
|
+
if self.sha256_text(current) != expected_sha256:
|
|
100
|
+
raise ToolError("file changed; expected hash does not match")
|
|
101
|
+
target.write_text(content, encoding="utf-8")
|
|
102
|
+
return {"path": str(target.relative_to(self.root)), "sha256": self.sha256_text(content)}
|
|
103
|
+
|
|
104
|
+
def create_text(self, path: str, content: str, overwrite: bool = False) -> Dict[str, object]:
|
|
105
|
+
target = self.resolve_path(path)
|
|
106
|
+
if target.exists() and not overwrite:
|
|
107
|
+
raise ValidationError("path already exists")
|
|
108
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
target.write_text(content, encoding="utf-8")
|
|
110
|
+
return {"path": str(target.relative_to(self.root)), "sha256": self.sha256_text(content)}
|
|
111
|
+
|
|
112
|
+
def delete_file(self, path: str) -> Dict[str, object]:
|
|
113
|
+
target = self.resolve_path(path)
|
|
114
|
+
if not target.exists() or not target.is_file():
|
|
115
|
+
raise ValidationError("path does not exist or is not a file")
|
|
116
|
+
if not self._is_git_tracked(target):
|
|
117
|
+
raise ToolError("delete blocked: file is not tracked by git")
|
|
118
|
+
target.unlink()
|
|
119
|
+
return {"path": str(target.relative_to(self.root)), "deleted": True}
|
|
120
|
+
|
|
121
|
+
def sha256_text(self, content: str) -> str:
|
|
122
|
+
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
123
|
+
|
|
124
|
+
def _is_git_tracked(self, target: Path) -> bool:
|
|
125
|
+
git_dir = self.root / ".git"
|
|
126
|
+
if not git_dir.exists():
|
|
127
|
+
return False
|
|
128
|
+
rel = str(target.relative_to(self.root))
|
|
129
|
+
result = subprocess.run(
|
|
130
|
+
["git", "ls-files", "--error-unmatch", rel],
|
|
131
|
+
cwd=self.root,
|
|
132
|
+
capture_output=True,
|
|
133
|
+
text=True,
|
|
134
|
+
)
|
|
135
|
+
return result.returncode == 0
|
cortex/tools/protocol.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Protocol helpers for tool calling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
TOOL_CALLS_START = "<tool_calls>"
|
|
9
|
+
TOOL_CALLS_END = "</tool_calls>"
|
|
10
|
+
TOOL_RESULTS_START = "<tool_results>"
|
|
11
|
+
TOOL_RESULTS_END = "</tool_results>"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def find_tool_calls_block(text: str) -> Tuple[Optional[int], Optional[int], Optional[str]]:
|
|
15
|
+
"""Return (start, end, block) for tool_calls JSON, if present."""
|
|
16
|
+
start = text.find(TOOL_CALLS_START)
|
|
17
|
+
if start == -1:
|
|
18
|
+
return None, None, None
|
|
19
|
+
end = text.find(TOOL_CALLS_END, start + len(TOOL_CALLS_START))
|
|
20
|
+
if end == -1:
|
|
21
|
+
return start, None, None
|
|
22
|
+
block = text[start + len(TOOL_CALLS_START) : end].strip()
|
|
23
|
+
return start, end + len(TOOL_CALLS_END), block
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def strip_tool_blocks(text: str) -> str:
|
|
27
|
+
"""Remove tool_calls block from text (including incomplete block)."""
|
|
28
|
+
start, end, _ = find_tool_calls_block(text)
|
|
29
|
+
if start is None:
|
|
30
|
+
return text
|
|
31
|
+
if end is None:
|
|
32
|
+
return text[:start]
|
|
33
|
+
return text[:start] + text[end:]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_tool_calls(text: str) -> Tuple[List[Dict[str, Any]], Optional[str]]:
|
|
37
|
+
"""Parse tool calls from text. Returns (calls, error)."""
|
|
38
|
+
start, end, block = find_tool_calls_block(text)
|
|
39
|
+
if start is None:
|
|
40
|
+
return [], None
|
|
41
|
+
if end is None or block is None:
|
|
42
|
+
return [], "tool_calls block is incomplete"
|
|
43
|
+
try:
|
|
44
|
+
payload = json.loads(block)
|
|
45
|
+
except json.JSONDecodeError as e:
|
|
46
|
+
return [], f"invalid tool_calls JSON: {e}"
|
|
47
|
+
|
|
48
|
+
if not isinstance(payload, dict):
|
|
49
|
+
return [], "tool_calls payload must be a JSON object"
|
|
50
|
+
calls = payload.get("calls")
|
|
51
|
+
if not isinstance(calls, list):
|
|
52
|
+
return [], "tool_calls payload missing 'calls' list"
|
|
53
|
+
|
|
54
|
+
normalized: List[Dict[str, Any]] = []
|
|
55
|
+
for idx, call in enumerate(calls):
|
|
56
|
+
if not isinstance(call, dict):
|
|
57
|
+
return [], f"tool call at index {idx} must be an object"
|
|
58
|
+
name = call.get("name")
|
|
59
|
+
arguments = call.get("arguments")
|
|
60
|
+
call_id = call.get("id") or f"call_{idx + 1}"
|
|
61
|
+
if not isinstance(name, str) or not name.strip():
|
|
62
|
+
return [], f"tool call at index {idx} missing valid name"
|
|
63
|
+
if arguments is None:
|
|
64
|
+
arguments = {}
|
|
65
|
+
if not isinstance(arguments, dict):
|
|
66
|
+
return [], f"tool call '{name}' arguments must be an object"
|
|
67
|
+
normalized.append({"id": str(call_id), "name": name, "arguments": arguments})
|
|
68
|
+
|
|
69
|
+
return normalized, None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def format_tool_results(results: List[Dict[str, Any]]) -> str:
|
|
73
|
+
"""Format tool results for model consumption."""
|
|
74
|
+
payload = {"results": results}
|
|
75
|
+
body = json.dumps(payload, ensure_ascii=True)
|
|
76
|
+
return f"{TOOL_RESULTS_START}\n{body}\n{TOOL_RESULTS_END}"
|
cortex/tools/search.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Search utilities for repo tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Dict, List
|
|
11
|
+
|
|
12
|
+
from cortex.tools.errors import ToolError, ValidationError
|
|
13
|
+
from cortex.tools.fs_ops import RepoFS
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RepoSearch:
|
|
17
|
+
"""Search helper constrained to a repo root."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, repo_fs: RepoFS) -> None:
|
|
20
|
+
self.repo_fs = repo_fs
|
|
21
|
+
|
|
22
|
+
def search(self, query: str, path: str = ".", use_regex: bool = True, max_results: int = 100) -> Dict[str, List[Dict[str, object]]]:
|
|
23
|
+
if not isinstance(query, str) or not query:
|
|
24
|
+
raise ValidationError("query must be a non-empty string")
|
|
25
|
+
if max_results < 1:
|
|
26
|
+
raise ValidationError("max_results must be >= 1")
|
|
27
|
+
root = self.repo_fs.root
|
|
28
|
+
target = self.repo_fs.resolve_path(path)
|
|
29
|
+
|
|
30
|
+
if shutil.which("rg"):
|
|
31
|
+
return {"results": self._rg_search(query, target, use_regex, max_results)}
|
|
32
|
+
return {"results": self._python_search(query, target, use_regex, max_results)}
|
|
33
|
+
|
|
34
|
+
def _rg_search(self, query: str, target: Path, use_regex: bool, max_results: int) -> List[Dict[str, object]]:
|
|
35
|
+
args = ["rg", "--line-number", "--with-filename", "--no-heading"]
|
|
36
|
+
if not use_regex:
|
|
37
|
+
args.append("-F")
|
|
38
|
+
args.extend(["-e", query, str(target)])
|
|
39
|
+
result = subprocess.run(args, cwd=self.repo_fs.root, capture_output=True, text=True)
|
|
40
|
+
if result.returncode not in (0, 1):
|
|
41
|
+
raise ToolError(f"rg failed: {result.stderr.strip()}")
|
|
42
|
+
matches: List[Dict[str, object]] = []
|
|
43
|
+
for line in result.stdout.splitlines():
|
|
44
|
+
try:
|
|
45
|
+
file_path, line_no, text = line.split(":", 2)
|
|
46
|
+
except ValueError:
|
|
47
|
+
continue
|
|
48
|
+
matches.append({"path": file_path, "line": int(line_no), "text": text})
|
|
49
|
+
if len(matches) >= max_results:
|
|
50
|
+
break
|
|
51
|
+
return matches
|
|
52
|
+
|
|
53
|
+
def _python_search(self, query: str, target: Path, use_regex: bool, max_results: int) -> List[Dict[str, object]]:
|
|
54
|
+
pattern = re.compile(query) if use_regex else None
|
|
55
|
+
results: List[Dict[str, object]] = []
|
|
56
|
+
for dirpath, dirnames, filenames in os.walk(target):
|
|
57
|
+
dirnames[:] = [d for d in dirnames if d != ".git"]
|
|
58
|
+
for name in filenames:
|
|
59
|
+
path = Path(dirpath) / name
|
|
60
|
+
try:
|
|
61
|
+
text = path.read_text(encoding="utf-8")
|
|
62
|
+
except Exception:
|
|
63
|
+
continue
|
|
64
|
+
for idx, line in enumerate(text.splitlines(), start=1):
|
|
65
|
+
found = bool(pattern.search(line)) if pattern else (query in line)
|
|
66
|
+
if found:
|
|
67
|
+
results.append({"path": str(path.relative_to(self.repo_fs.root)), "line": idx, "text": line})
|
|
68
|
+
if len(results) >= max_results:
|
|
69
|
+
return results
|
|
70
|
+
return results
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Tool runner and specifications for Cortex."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from cortex.tools.errors import ToolError, ValidationError
|
|
11
|
+
from cortex.tools.fs_ops import RepoFS
|
|
12
|
+
from cortex.tools.search import RepoSearch
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
ConfirmCallback = Callable[[str], bool]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ToolRunner:
|
|
19
|
+
"""Execute tool calls with safety checks."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, root: Path, confirm_callback: Optional[ConfirmCallback] = None) -> None:
|
|
22
|
+
self.fs = RepoFS(root)
|
|
23
|
+
self.search = RepoSearch(self.fs)
|
|
24
|
+
self.confirm_callback = confirm_callback
|
|
25
|
+
|
|
26
|
+
def set_confirm_callback(self, callback: ConfirmCallback) -> None:
|
|
27
|
+
self.confirm_callback = callback
|
|
28
|
+
|
|
29
|
+
def tool_spec(self) -> Dict[str, Any]:
|
|
30
|
+
return {
|
|
31
|
+
"list_dir": {"args": {"path": "string", "recursive": "bool", "max_depth": "int"}},
|
|
32
|
+
"read_file": {"args": {"path": "string", "start_line": "int", "end_line": "int", "max_bytes": "int"}},
|
|
33
|
+
"search": {"args": {"query": "string", "path": "string", "use_regex": "bool", "max_results": "int"}},
|
|
34
|
+
"write_file": {"args": {"path": "string", "content": "string", "expected_sha256": "string"}},
|
|
35
|
+
"create_file": {"args": {"path": "string", "content": "string", "overwrite": "bool"}},
|
|
36
|
+
"delete_file": {"args": {"path": "string"}},
|
|
37
|
+
"replace_in_file": {"args": {"path": "string", "old": "string", "new": "string", "expected_replacements": "int"}},
|
|
38
|
+
"insert_after": {"args": {"path": "string", "anchor": "string", "content": "string", "expected_matches": "int"}},
|
|
39
|
+
"insert_before": {"args": {"path": "string", "anchor": "string", "content": "string", "expected_matches": "int"}},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
def tool_instructions(self) -> str:
|
|
43
|
+
spec = json.dumps(self.tool_spec(), ensure_ascii=True, indent=2)
|
|
44
|
+
repo_root = str(self.fs.root)
|
|
45
|
+
return (
|
|
46
|
+
"[CORTEX_TOOL_INSTRUCTIONS v2]\n"
|
|
47
|
+
"You have access to file tools. If a tool is required, respond ONLY with a <tool_calls> JSON block.\n"
|
|
48
|
+
"Do not include any other text when calling tools.\n"
|
|
49
|
+
f"Repo root: {repo_root}\n"
|
|
50
|
+
"All paths must be relative to the repo root (use '.' for root). Do not use absolute paths or ~.\n"
|
|
51
|
+
"If you are unsure about paths, call list_dir with path '.' first.\n"
|
|
52
|
+
"Format:\n"
|
|
53
|
+
"<tool_calls>{\"calls\":[{\"id\":\"call_1\",\"name\":\"tool_name\",\"arguments\":{...}}]}</tool_calls>\n"
|
|
54
|
+
"Available tools:\n"
|
|
55
|
+
f"{spec}"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def run_calls(self, calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
59
|
+
results: List[Dict[str, Any]] = []
|
|
60
|
+
for call in calls:
|
|
61
|
+
call_id = call.get("id", "unknown")
|
|
62
|
+
name = call.get("name")
|
|
63
|
+
args = call.get("arguments") or {}
|
|
64
|
+
try:
|
|
65
|
+
if name == "list_dir":
|
|
66
|
+
result = self.fs.list_dir(**args)
|
|
67
|
+
elif name == "read_file":
|
|
68
|
+
result = self.fs.read_text(**args)
|
|
69
|
+
elif name == "search":
|
|
70
|
+
result = self.search.search(**args)
|
|
71
|
+
elif name == "write_file":
|
|
72
|
+
result = self._write_file(**args)
|
|
73
|
+
elif name == "create_file":
|
|
74
|
+
result = self._create_file(**args)
|
|
75
|
+
elif name == "delete_file":
|
|
76
|
+
result = self._delete_file(**args)
|
|
77
|
+
elif name == "replace_in_file":
|
|
78
|
+
result = self._replace_in_file(**args)
|
|
79
|
+
elif name == "insert_after":
|
|
80
|
+
result = self._insert_relative(after=True, **args)
|
|
81
|
+
elif name == "insert_before":
|
|
82
|
+
result = self._insert_relative(after=False, **args)
|
|
83
|
+
else:
|
|
84
|
+
raise ValidationError(f"unknown tool: {name}")
|
|
85
|
+
results.append({"id": call_id, "name": name, "ok": True, "result": result, "error": None})
|
|
86
|
+
except Exception as e:
|
|
87
|
+
results.append({"id": call_id, "name": name, "ok": False, "result": None, "error": str(e)})
|
|
88
|
+
return results
|
|
89
|
+
|
|
90
|
+
def _write_file(self, path: str, content: str, expected_sha256: Optional[str] = None) -> Dict[str, Any]:
|
|
91
|
+
before = self.fs.read_full_text(path)
|
|
92
|
+
self._confirm_change(path, before, content, "write")
|
|
93
|
+
return self.fs.write_text(path, content, expected_sha256=expected_sha256)
|
|
94
|
+
|
|
95
|
+
def _create_file(self, path: str, content: str, overwrite: bool = False) -> Dict[str, Any]:
|
|
96
|
+
before = ""
|
|
97
|
+
self._confirm_change(path, before, content, "create")
|
|
98
|
+
return self.fs.create_text(path, content, overwrite=overwrite)
|
|
99
|
+
|
|
100
|
+
def _delete_file(self, path: str) -> Dict[str, Any]:
|
|
101
|
+
before = self.fs.read_full_text(path)
|
|
102
|
+
self._confirm_change(path, before, "", "delete")
|
|
103
|
+
return self.fs.delete_file(path)
|
|
104
|
+
|
|
105
|
+
def _replace_in_file(self, path: str, old: str, new: str, expected_replacements: int = 1) -> Dict[str, Any]:
|
|
106
|
+
if not old:
|
|
107
|
+
raise ValidationError("old must be a non-empty string")
|
|
108
|
+
content = self.fs.read_full_text(path)
|
|
109
|
+
count = content.count(old)
|
|
110
|
+
if count != expected_replacements:
|
|
111
|
+
raise ToolError(f"expected {expected_replacements} replacements, found {count}")
|
|
112
|
+
updated = content.replace(old, new)
|
|
113
|
+
self._confirm_change(path, content, updated, "replace")
|
|
114
|
+
return self.fs.write_text(path, updated)
|
|
115
|
+
|
|
116
|
+
def _insert_relative(self, path: str, anchor: str, content: str, expected_matches: int = 1, after: bool = True) -> Dict[str, Any]:
|
|
117
|
+
if not anchor:
|
|
118
|
+
raise ValidationError("anchor must be a non-empty string")
|
|
119
|
+
original = self.fs.read_full_text(path)
|
|
120
|
+
count = original.count(anchor)
|
|
121
|
+
if count != expected_matches:
|
|
122
|
+
raise ToolError(f"expected {expected_matches} matches, found {count}")
|
|
123
|
+
insert_text = anchor + content if after else content + anchor
|
|
124
|
+
updated = original.replace(anchor, insert_text, count if expected_matches > 1 else 1)
|
|
125
|
+
self._confirm_change(path, original, updated, "insert")
|
|
126
|
+
return self.fs.write_text(path, updated)
|
|
127
|
+
|
|
128
|
+
def _confirm_change(self, path: str, before: str, after: str, action: str) -> None:
|
|
129
|
+
if self.confirm_callback is None:
|
|
130
|
+
raise ToolError("confirmation required but no callback configured")
|
|
131
|
+
if before == after:
|
|
132
|
+
raise ToolError("no changes to apply")
|
|
133
|
+
diff = "\n".join(
|
|
134
|
+
difflib.unified_diff(
|
|
135
|
+
before.splitlines(),
|
|
136
|
+
after.splitlines(),
|
|
137
|
+
fromfile=f"{path} (before)",
|
|
138
|
+
tofile=f"{path} (after)",
|
|
139
|
+
lineterm="",
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
prompt = f"Apply {action} to {path}?\n{diff}\n"
|
|
143
|
+
if not self.confirm_callback(prompt):
|
|
144
|
+
raise ToolError("change declined by user")
|
cortex/ui/cli.py
CHANGED
|
@@ -18,6 +18,7 @@ from textwrap import wrap
|
|
|
18
18
|
|
|
19
19
|
from rich.live import Live
|
|
20
20
|
from rich.style import Style
|
|
21
|
+
from rich.text import Text
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
logger = logging.getLogger(__name__)
|
|
@@ -30,6 +31,8 @@ from cortex.conversation_manager import ConversationManager, MessageRole
|
|
|
30
31
|
from cortex.model_downloader import ModelDownloader
|
|
31
32
|
from cortex.template_registry import TemplateRegistry
|
|
32
33
|
from cortex.fine_tuning import FineTuneWizard
|
|
34
|
+
from cortex.tools import ToolRunner
|
|
35
|
+
from cortex.tools import protocol as tool_protocol
|
|
33
36
|
from cortex.ui.markdown_render import ThinkMarkdown, PrefixedRenderable, render_plain_with_think
|
|
34
37
|
|
|
35
38
|
|
|
@@ -58,6 +61,11 @@ class CortexCLI:
|
|
|
58
61
|
|
|
59
62
|
# Initialize fine-tuning wizard
|
|
60
63
|
self.fine_tune_wizard = FineTuneWizard(model_manager, config)
|
|
64
|
+
|
|
65
|
+
# Tooling support (always enabled)
|
|
66
|
+
self.tool_runner = ToolRunner(Path.cwd())
|
|
67
|
+
self.tool_runner.set_confirm_callback(self._confirm_tool_change)
|
|
68
|
+
self.max_tool_iterations = 4
|
|
61
69
|
|
|
62
70
|
|
|
63
71
|
self.running = True
|
|
@@ -132,6 +140,86 @@ class CortexCLI:
|
|
|
132
140
|
# Don't call sys.exit() here - let the main loop exit naturally
|
|
133
141
|
# This prevents traceback from the parent process
|
|
134
142
|
print("\n", file=sys.stderr) # Just add a newline for cleaner output
|
|
143
|
+
|
|
144
|
+
def _confirm_tool_change(self, prompt: str) -> bool:
|
|
145
|
+
"""Prompt user to approve a tool-driven change."""
|
|
146
|
+
print("\n" + prompt)
|
|
147
|
+
response = input("Apply change? [y/N]: ").strip().lower()
|
|
148
|
+
return response in {"y", "yes"}
|
|
149
|
+
|
|
150
|
+
def _ensure_tool_instructions(self) -> None:
|
|
151
|
+
"""Inject tool instructions into the conversation once."""
|
|
152
|
+
conversation = self.conversation_manager.get_current_conversation()
|
|
153
|
+
if conversation is None:
|
|
154
|
+
conversation = self.conversation_manager.new_conversation()
|
|
155
|
+
marker = "[CORTEX_TOOL_INSTRUCTIONS v2]"
|
|
156
|
+
for message in conversation.messages:
|
|
157
|
+
if message.role == MessageRole.SYSTEM and marker in message.content:
|
|
158
|
+
return
|
|
159
|
+
self.conversation_manager.add_message(MessageRole.SYSTEM, self.tool_runner.tool_instructions())
|
|
160
|
+
|
|
161
|
+
def _summarize_tool_call(self, call: dict) -> str:
|
|
162
|
+
name = str(call.get("name", "tool"))
|
|
163
|
+
args = call.get("arguments") or {}
|
|
164
|
+
parts = []
|
|
165
|
+
preferred = ("path", "query", "anchor", "start_line", "end_line", "recursive", "max_results")
|
|
166
|
+
for key in preferred:
|
|
167
|
+
if key in args:
|
|
168
|
+
value = args[key]
|
|
169
|
+
if isinstance(value, str) and len(value) > 60:
|
|
170
|
+
value = value[:57] + "..."
|
|
171
|
+
parts.append(f"{key}={value!r}")
|
|
172
|
+
if not parts and args:
|
|
173
|
+
for key in list(args.keys())[:3]:
|
|
174
|
+
value = args[key]
|
|
175
|
+
if isinstance(value, str) and len(value) > 60:
|
|
176
|
+
value = value[:57] + "..."
|
|
177
|
+
parts.append(f"{key}={value!r}")
|
|
178
|
+
arg_str = ", ".join(parts)
|
|
179
|
+
return f"{name}({arg_str})" if arg_str else f"{name}()"
|
|
180
|
+
|
|
181
|
+
def _summarize_tool_result(self, result: dict) -> str:
|
|
182
|
+
name = str(result.get("name", "tool"))
|
|
183
|
+
if not result.get("ok", False):
|
|
184
|
+
error = result.get("error") or "unknown error"
|
|
185
|
+
return f"{name} -> error: {error}"
|
|
186
|
+
payload = result.get("result") or {}
|
|
187
|
+
if name == "list_dir":
|
|
188
|
+
entries = payload.get("entries") or []
|
|
189
|
+
return f"{name} -> entries={len(entries)}"
|
|
190
|
+
if name == "search":
|
|
191
|
+
matches = payload.get("results") or []
|
|
192
|
+
return f"{name} -> results={len(matches)}"
|
|
193
|
+
if name == "read_file":
|
|
194
|
+
path = payload.get("path") or ""
|
|
195
|
+
start = payload.get("start_line")
|
|
196
|
+
end = payload.get("end_line")
|
|
197
|
+
if start and end:
|
|
198
|
+
return f"{name} -> {path} lines {start}-{end}"
|
|
199
|
+
if start:
|
|
200
|
+
return f"{name} -> {path} from line {start}"
|
|
201
|
+
return f"{name} -> {path}"
|
|
202
|
+
if name in {"write_file", "create_file", "delete_file", "replace_in_file", "insert_after", "insert_before"}:
|
|
203
|
+
path = payload.get("path") or ""
|
|
204
|
+
return f"{name} -> {path}"
|
|
205
|
+
return f"{name} -> ok"
|
|
206
|
+
|
|
207
|
+
def _print_tool_activity(self, tool_calls: list, tool_results: list) -> None:
|
|
208
|
+
lines = []
|
|
209
|
+
for call, result in zip(tool_calls, tool_results):
|
|
210
|
+
lines.append(f"tool {self._summarize_tool_call(call)} -> {self._summarize_tool_result(result)}")
|
|
211
|
+
if not lines:
|
|
212
|
+
return
|
|
213
|
+
text = Text("\n".join(lines), style=Style(color="bright_black", italic=True))
|
|
214
|
+
renderable = PrefixedRenderable(text, prefix=" ", prefix_style=Style(dim=True), indent=" ", auto_space=False)
|
|
215
|
+
original_console_width = self.console._width
|
|
216
|
+
target_width = max(40, int(self.get_terminal_width() * 0.75))
|
|
217
|
+
self.console.width = target_width
|
|
218
|
+
try:
|
|
219
|
+
self.console.print(renderable, highlight=False, soft_wrap=True)
|
|
220
|
+
self.console.print()
|
|
221
|
+
finally:
|
|
222
|
+
self.console._width = original_console_width
|
|
135
223
|
|
|
136
224
|
|
|
137
225
|
def get_terminal_width(self) -> int:
|
|
@@ -1110,16 +1198,10 @@ class CortexCLI:
|
|
|
1110
1198
|
except Exception as e:
|
|
1111
1199
|
logger.debug(f"Failed to get template profile: {e}")
|
|
1112
1200
|
|
|
1113
|
-
#
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
#
|
|
1117
|
-
# This is crucial for debugging when models give unexpected responses
|
|
1118
|
-
# It shows the formatted prompt with all special tokens and formatting
|
|
1119
|
-
# print(f"\033[33m[DEBUG] Formatted prompt being sent to model:\033[0m", file=sys.stderr)
|
|
1120
|
-
# print(f"\033[33m{repr(formatted_prompt[:200])}...\033[0m", file=sys.stderr)
|
|
1121
|
-
|
|
1122
|
-
# Now add user message to conversation history
|
|
1201
|
+
# Ensure tool instructions are present before adding user message
|
|
1202
|
+
self._ensure_tool_instructions()
|
|
1203
|
+
|
|
1204
|
+
# Now add user message to conversation history
|
|
1123
1205
|
self.conversation_manager.add_message(MessageRole.USER, user_input)
|
|
1124
1206
|
|
|
1125
1207
|
# Start response on a new line; prefix is rendered with the markdown output.
|
|
@@ -1134,130 +1216,154 @@ class CortexCLI:
|
|
|
1134
1216
|
except Exception as e:
|
|
1135
1217
|
logger.debug(f"Could not get stop sequences: {e}")
|
|
1136
1218
|
|
|
1137
|
-
#
|
|
1138
|
-
request = GenerationRequest(
|
|
1139
|
-
prompt=formatted_prompt,
|
|
1140
|
-
max_tokens=self.config.inference.max_tokens,
|
|
1141
|
-
temperature=self.config.inference.temperature,
|
|
1142
|
-
top_p=self.config.inference.top_p,
|
|
1143
|
-
top_k=self.config.inference.top_k,
|
|
1144
|
-
repetition_penalty=self.config.inference.repetition_penalty,
|
|
1145
|
-
stream=self.config.inference.stream_output,
|
|
1146
|
-
seed=self.config.inference.seed if self.config.inference.seed >= 0 else None,
|
|
1147
|
-
stop_sequences=stop_sequences
|
|
1148
|
-
)
|
|
1149
|
-
|
|
1150
|
-
# Generate response
|
|
1219
|
+
# Generate response (with tool loop)
|
|
1151
1220
|
self.generating = True
|
|
1152
|
-
generated_text = ""
|
|
1153
|
-
start_time = time.time()
|
|
1154
|
-
token_count = 0
|
|
1155
|
-
first_token_time = None
|
|
1156
1221
|
|
|
1157
1222
|
try:
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
template_profile.reset_streaming_state()
|
|
1223
|
+
tool_iterations = 0
|
|
1224
|
+
while tool_iterations < self.max_tool_iterations:
|
|
1225
|
+
tool_iterations += 1
|
|
1162
1226
|
|
|
1163
|
-
|
|
1164
|
-
accumulated_response = ""
|
|
1165
|
-
last_render_time = 0.0
|
|
1166
|
-
render_interval = 0.05 # seconds
|
|
1167
|
-
prefix_style = Style(color="cyan")
|
|
1227
|
+
formatted_prompt = self._format_prompt_with_chat_template(user_input, include_user=False)
|
|
1168
1228
|
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
text,
|
|
1173
|
-
code_theme="monokai",
|
|
1174
|
-
use_line_numbers=False,
|
|
1175
|
-
syntax_highlighting=getattr(self.config.ui, "syntax_highlighting", True),
|
|
1176
|
-
)
|
|
1177
|
-
renderable = markdown
|
|
1178
|
-
else:
|
|
1179
|
-
renderable = render_plain_with_think(text)
|
|
1229
|
+
# DEBUG: Uncomment these lines to see the exact prompt being sent to the model
|
|
1230
|
+
# print(f"\033[33m[DEBUG] Formatted prompt being sent to model:\033[0m", file=sys.stderr)
|
|
1231
|
+
# print(f"\033[33m{repr(formatted_prompt[:200])}...\033[0m", file=sys.stderr)
|
|
1180
1232
|
|
|
1181
|
-
|
|
1233
|
+
request = GenerationRequest(
|
|
1234
|
+
prompt=formatted_prompt,
|
|
1235
|
+
max_tokens=self.config.inference.max_tokens,
|
|
1236
|
+
temperature=self.config.inference.temperature,
|
|
1237
|
+
top_p=self.config.inference.top_p,
|
|
1238
|
+
top_k=self.config.inference.top_k,
|
|
1239
|
+
repetition_penalty=self.config.inference.repetition_penalty,
|
|
1240
|
+
stream=self.config.inference.stream_output,
|
|
1241
|
+
seed=self.config.inference.seed if self.config.inference.seed >= 0 else None,
|
|
1242
|
+
stop_sequences=stop_sequences
|
|
1243
|
+
)
|
|
1182
1244
|
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
build_renderable(""),
|
|
1189
|
-
console=self.console,
|
|
1190
|
-
auto_refresh=False,
|
|
1191
|
-
refresh_per_second=20,
|
|
1192
|
-
transient=False,
|
|
1193
|
-
vertical_overflow="visible",
|
|
1194
|
-
) as live:
|
|
1195
|
-
for token in self.inference_engine.generate(request):
|
|
1196
|
-
if first_token_time is None:
|
|
1197
|
-
first_token_time = time.time()
|
|
1245
|
+
generated_text = ""
|
|
1246
|
+
start_time = time.time()
|
|
1247
|
+
token_count = 0
|
|
1248
|
+
first_token_time = None
|
|
1249
|
+
tool_calls_started = False
|
|
1198
1250
|
|
|
1199
|
-
|
|
1200
|
-
|
|
1251
|
+
if uses_reasoning_template and template_profile and template_profile.supports_streaming():
|
|
1252
|
+
if hasattr(template_profile, 'reset_streaming_state'):
|
|
1253
|
+
template_profile.reset_streaming_state()
|
|
1201
1254
|
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1255
|
+
display_text = ""
|
|
1256
|
+
accumulated_response = ""
|
|
1257
|
+
last_render_time = 0.0
|
|
1258
|
+
render_interval = 0.05 # seconds
|
|
1259
|
+
prefix_style = Style(color="cyan")
|
|
1260
|
+
|
|
1261
|
+
def build_renderable(text: str):
|
|
1262
|
+
if getattr(self.config.ui, "markdown_rendering", True):
|
|
1263
|
+
markdown = ThinkMarkdown(
|
|
1264
|
+
text,
|
|
1265
|
+
code_theme="monokai",
|
|
1266
|
+
use_line_numbers=False,
|
|
1267
|
+
syntax_highlighting=getattr(self.config.ui, "syntax_highlighting", True),
|
|
1268
|
+
)
|
|
1269
|
+
renderable = markdown
|
|
1270
|
+
else:
|
|
1271
|
+
renderable = render_plain_with_think(text)
|
|
1210
1272
|
|
|
1211
|
-
|
|
1212
|
-
display_text += display_token
|
|
1273
|
+
return PrefixedRenderable(renderable, prefix="⏺", prefix_style=prefix_style, indent=" ", auto_space=True)
|
|
1213
1274
|
|
|
1214
|
-
|
|
1215
|
-
|
|
1275
|
+
original_console_width = self.console._width
|
|
1276
|
+
target_width = max(40, int(self.get_terminal_width() * 0.75))
|
|
1277
|
+
self.console.width = target_width
|
|
1278
|
+
try:
|
|
1279
|
+
with Live(
|
|
1280
|
+
build_renderable(""),
|
|
1281
|
+
console=self.console,
|
|
1282
|
+
auto_refresh=False,
|
|
1283
|
+
refresh_per_second=20,
|
|
1284
|
+
transient=False,
|
|
1285
|
+
vertical_overflow="visible",
|
|
1286
|
+
) as live:
|
|
1287
|
+
for token in self.inference_engine.generate(request):
|
|
1288
|
+
if first_token_time is None:
|
|
1289
|
+
first_token_time = time.time()
|
|
1290
|
+
|
|
1291
|
+
generated_text += token
|
|
1292
|
+
token_count += 1
|
|
1293
|
+
|
|
1294
|
+
if not tool_calls_started and tool_protocol.find_tool_calls_block(generated_text)[0] is not None:
|
|
1295
|
+
tool_calls_started = True
|
|
1296
|
+
display_text = "<think>tools running...</think>"
|
|
1297
|
+
live.update(build_renderable(display_text), refresh=True)
|
|
1298
|
+
|
|
1299
|
+
display_token = token
|
|
1300
|
+
if uses_reasoning_template and template_profile and template_profile.supports_streaming():
|
|
1301
|
+
display_token, should_display = template_profile.process_streaming_response(
|
|
1302
|
+
token, accumulated_response
|
|
1303
|
+
)
|
|
1304
|
+
accumulated_response += token
|
|
1305
|
+
if not should_display:
|
|
1306
|
+
display_token = ""
|
|
1307
|
+
|
|
1308
|
+
if not tool_calls_started and display_token:
|
|
1309
|
+
display_text += display_token
|
|
1310
|
+
|
|
1311
|
+
now = time.time()
|
|
1312
|
+
if (not tool_calls_started and display_token and
|
|
1313
|
+
("\n" in display_token or now - last_render_time >= render_interval)):
|
|
1314
|
+
live.update(build_renderable(display_text), refresh=True)
|
|
1315
|
+
last_render_time = now
|
|
1316
|
+
|
|
1317
|
+
if not tool_calls_started and uses_reasoning_template and template_profile:
|
|
1318
|
+
final_text = template_profile.process_response(generated_text)
|
|
1319
|
+
generated_text = final_text
|
|
1320
|
+
if not template_profile.config.show_reasoning:
|
|
1321
|
+
display_text = final_text
|
|
1216
1322
|
live.update(build_renderable(display_text), refresh=True)
|
|
1217
|
-
|
|
1323
|
+
finally:
|
|
1324
|
+
self.console._width = original_console_width
|
|
1218
1325
|
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
if not template_profile.config.show_reasoning:
|
|
1223
|
-
display_text = final_text
|
|
1326
|
+
tool_calls, parse_error = tool_protocol.parse_tool_calls(generated_text)
|
|
1327
|
+
if parse_error:
|
|
1328
|
+
print(f"\n\033[31m✗ Tool call parse error:\033[0m {parse_error}", file=sys.stderr)
|
|
1224
1329
|
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1330
|
+
if tool_calls:
|
|
1331
|
+
tool_results = self.tool_runner.run_calls(tool_calls)
|
|
1332
|
+
self._print_tool_activity(tool_calls, tool_results)
|
|
1333
|
+
self.conversation_manager.add_message(
|
|
1334
|
+
MessageRole.SYSTEM,
|
|
1335
|
+
tool_protocol.format_tool_results(tool_results)
|
|
1336
|
+
)
|
|
1337
|
+
if tool_iterations >= self.max_tool_iterations:
|
|
1338
|
+
print("\n\033[31m✗\033[0m Tool loop limit reached.", file=sys.stderr)
|
|
1339
|
+
break
|
|
1340
|
+
continue
|
|
1228
1341
|
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
metrics_parts
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
print(f" \033[2m{metrics_line}\033[0m")
|
|
1255
|
-
|
|
1256
|
-
if token_count >= request.max_tokens:
|
|
1257
|
-
print(f" \033[2m(output truncated at max_tokens={request.max_tokens}; increase in config.yaml)\033[0m")
|
|
1258
|
-
|
|
1259
|
-
# Add assistant message to conversation history
|
|
1260
|
-
self.conversation_manager.add_message(MessageRole.ASSISTANT, generated_text)
|
|
1342
|
+
final_text = generated_text
|
|
1343
|
+
if parse_error:
|
|
1344
|
+
final_text = tool_protocol.strip_tool_blocks(generated_text)
|
|
1345
|
+
if tool_calls_started and final_text.strip():
|
|
1346
|
+
self.console.print(build_renderable(final_text))
|
|
1347
|
+
|
|
1348
|
+
elapsed = time.time() - start_time
|
|
1349
|
+
if token_count > 0 and elapsed > 0:
|
|
1350
|
+
tokens_per_sec = token_count / elapsed
|
|
1351
|
+
first_token_latency = first_token_time - start_time if first_token_time else 0
|
|
1352
|
+
|
|
1353
|
+
metrics_parts = []
|
|
1354
|
+
if first_token_latency > 0.1:
|
|
1355
|
+
metrics_parts.append(f"first {first_token_latency:.2f}s")
|
|
1356
|
+
metrics_parts.append(f"total {elapsed:.1f}s")
|
|
1357
|
+
metrics_parts.append(f"tokens {token_count}")
|
|
1358
|
+
metrics_parts.append(f"speed {tokens_per_sec:.1f} tok/s")
|
|
1359
|
+
metrics_line = " · ".join(metrics_parts)
|
|
1360
|
+
print(f" \033[2m{metrics_line}\033[0m")
|
|
1361
|
+
|
|
1362
|
+
if token_count >= request.max_tokens:
|
|
1363
|
+
print(f" \033[2m(output truncated at max_tokens={request.max_tokens}; increase in config.yaml)\033[0m")
|
|
1364
|
+
|
|
1365
|
+
self.conversation_manager.add_message(MessageRole.ASSISTANT, final_text)
|
|
1366
|
+
break
|
|
1261
1367
|
|
|
1262
1368
|
except Exception as e:
|
|
1263
1369
|
print(f"\n\033[31m✗ Error:\033[0m {str(e)}", file=sys.stderr)
|
|
@@ -1274,7 +1380,7 @@ class CortexCLI:
|
|
|
1274
1380
|
except (KeyboardInterrupt, EOFError):
|
|
1275
1381
|
raise
|
|
1276
1382
|
|
|
1277
|
-
def _format_prompt_with_chat_template(self, user_input: str) -> str:
|
|
1383
|
+
def _format_prompt_with_chat_template(self, user_input: str, include_user: bool = True) -> str:
|
|
1278
1384
|
"""Format the prompt with appropriate chat template for the model."""
|
|
1279
1385
|
# Get current conversation context
|
|
1280
1386
|
conversation = self.conversation_manager.get_current_conversation()
|
|
@@ -1297,10 +1403,11 @@ class CortexCLI:
|
|
|
1297
1403
|
})
|
|
1298
1404
|
|
|
1299
1405
|
# Add current user message
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1406
|
+
if include_user:
|
|
1407
|
+
messages.append({
|
|
1408
|
+
"role": "user",
|
|
1409
|
+
"content": user_input
|
|
1410
|
+
})
|
|
1304
1411
|
|
|
1305
1412
|
# Use template registry to format messages
|
|
1306
1413
|
try:
|
cortex/ui/markdown_render.py
CHANGED
|
@@ -190,11 +190,13 @@ class PrefixedRenderable:
|
|
|
190
190
|
prefix: str,
|
|
191
191
|
prefix_style: Style | None = None,
|
|
192
192
|
indent: str | None = None,
|
|
193
|
+
auto_space: bool = False,
|
|
193
194
|
) -> None:
|
|
194
195
|
self.renderable = renderable
|
|
195
196
|
self.prefix = prefix
|
|
196
197
|
self.prefix_style = prefix_style
|
|
197
198
|
self.indent = indent if indent is not None else " " * len(prefix)
|
|
199
|
+
self.auto_space = auto_space
|
|
198
200
|
|
|
199
201
|
def __rich_console__(self, console: Console, options):
|
|
200
202
|
prefix_width = cell_len(self.prefix)
|
|
@@ -205,6 +207,7 @@ class PrefixedRenderable:
|
|
|
205
207
|
|
|
206
208
|
yield Segment(self.prefix, self.prefix_style)
|
|
207
209
|
|
|
210
|
+
inserted_space = False
|
|
208
211
|
for segment in console.render(self.renderable, inner_options):
|
|
209
212
|
if segment.control:
|
|
210
213
|
yield segment
|
|
@@ -213,6 +216,12 @@ class PrefixedRenderable:
|
|
|
213
216
|
text = segment.text
|
|
214
217
|
style = segment.style
|
|
215
218
|
|
|
219
|
+
if self.auto_space and not inserted_space:
|
|
220
|
+
if text:
|
|
221
|
+
if not text[0].isspace():
|
|
222
|
+
yield Segment(" ", None)
|
|
223
|
+
inserted_space = True
|
|
224
|
+
|
|
216
225
|
if "\n" not in text:
|
|
217
226
|
yield segment
|
|
218
227
|
continue
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cortex-llm
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.10
|
|
4
4
|
Summary: GPU-Accelerated LLM Terminal for Apple Silicon
|
|
5
5
|
Home-page: https://github.com/faisalmumtaz/Cortex
|
|
6
6
|
Author: Cortex Development Team
|
|
@@ -131,6 +131,10 @@ Cortex supports:
|
|
|
131
131
|
- `docs/template-registry.md`
|
|
132
132
|
- **Inference engine details** and backend behavior
|
|
133
133
|
- `docs/inference-engine.md`
|
|
134
|
+
- **Tooling (experimental, WIP)** for repo-scoped read/search and optional file edits with explicit confirmation
|
|
135
|
+
- `docs/cli.md`
|
|
136
|
+
|
|
137
|
+
**Important (Work in Progress):** Tooling is actively evolving and should be considered experimental. Behavior, output format, and available actions may change; tool calls can fail; and UI presentation may be adjusted. Use tooling on non-critical work first, and always review any proposed file changes before approving them.
|
|
134
138
|
|
|
135
139
|
## Configuration
|
|
136
140
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
cortex/__init__.py,sha256=
|
|
1
|
+
cortex/__init__.py,sha256=xl3alSv0U-9AkcwCu66nZJNgNP7Hwfs1_tjAJUXfEQI,2203
|
|
2
2
|
cortex/__main__.py,sha256=I7Njt7BjGoHtPhftDoA44OyOYbwWNNaPwP_qlJSn0J4,2857
|
|
3
3
|
cortex/config.py,sha256=IQnMaXznTflTSvr91aybtPMnNW088r-BYeVMhxny63w,13444
|
|
4
4
|
cortex/conversation_manager.py,sha256=aSTdGjVttsMKIiRPzztP0tOXlqZBkWtgZDNCZGyaR-c,17177
|
|
5
5
|
cortex/gpu_validator.py,sha256=un6vMQ78MWMnKWIz8n-92v9Fb4g_YXqU_E1pUPinncY,16582
|
|
6
|
-
cortex/inference_engine.py,sha256=
|
|
6
|
+
cortex/inference_engine.py,sha256=4ayAjw5aRVOFfRffKmKkwCMMYBnrrGqT7xbIz1rGbIE,30907
|
|
7
7
|
cortex/model_downloader.py,sha256=VuPhvxq_66qKjsPjEWcLW-VmUHzOHik6LBMiGDk-cX8,4977
|
|
8
8
|
cortex/model_manager.py,sha256=Ra21TjhtFS-7_hRzDMh9m0BUazIGWoKr7Gye3GiVRJM,102671
|
|
9
9
|
cortex/fine_tuning/__init__.py,sha256=IXKQqNqN1C3mha3na35i7KI-hMnsqqrmUgV4NrPKHy0,269
|
|
@@ -37,13 +37,19 @@ cortex/template_registry/template_profiles/standard/chatml.py,sha256=_oQdqV90bGN
|
|
|
37
37
|
cortex/template_registry/template_profiles/standard/gemma.py,sha256=D4wZN3_6QzUj-icfkX6o35USLbhXe08VaYGclHXvHSw,4074
|
|
38
38
|
cortex/template_registry/template_profiles/standard/llama.py,sha256=jz4MyvmISSPtIAcffPE7LrTosHvlC0NoJhzTw1DCvpY,3209
|
|
39
39
|
cortex/template_registry/template_profiles/standard/simple.py,sha256=dGOOcL6HRoJFxkixLrYC4w7c63h-QmOOWC2TsOihYog,2422
|
|
40
|
+
cortex/tools/__init__.py,sha256=gwOg8T6UTg8nDefV6lr6FpEEq_9-kK59Var47PiNZGw,113
|
|
41
|
+
cortex/tools/errors.py,sha256=BAGznHVle7HqBLsy_Z4xF9uhbL8ZYtZC2d1z9nS4M4Q,203
|
|
42
|
+
cortex/tools/fs_ops.py,sha256=8VeIURphURLwBZworIw1fD72wliX_jf8qfJt75zMAzc,5993
|
|
43
|
+
cortex/tools/protocol.py,sha256=_dKiHJe0-RuW6lwVmL1XXmVPL6ms4bCF4fJMLl06e0c,2760
|
|
44
|
+
cortex/tools/search.py,sha256=cc0-xCixvOlbDw6tewpGLwvqXmdKEC1d5I7fyDJx79M,2972
|
|
45
|
+
cortex/tools/tool_runner.py,sha256=lbp-E02jNayvA5NUszWNTunskGClj-CHt5wU5SEyZE8,6995
|
|
40
46
|
cortex/ui/__init__.py,sha256=t3GrHJMHTVgBEKh2_qt4B9mS594V5jriTDqc3eZKMGc,3409
|
|
41
|
-
cortex/ui/cli.py,sha256=
|
|
42
|
-
cortex/ui/markdown_render.py,sha256=
|
|
47
|
+
cortex/ui/cli.py,sha256=ZHdlB7vTckBLTZfTPpjSjFobA6rjVN_4ZIuyKTOkeUg,81317
|
|
48
|
+
cortex/ui/markdown_render.py,sha256=KAFBF5XUnhw1G7ZB9wMnLQyvJ4GCIW8uGK7auoKkrr4,8096
|
|
43
49
|
cortex/ui/terminal_app.py,sha256=SF3KqcGFyZ4hpTmgX21idPzOTJLdKGkt4QdA-wwUBNE,18317
|
|
44
|
-
cortex_llm-1.0.
|
|
45
|
-
cortex_llm-1.0.
|
|
46
|
-
cortex_llm-1.0.
|
|
47
|
-
cortex_llm-1.0.
|
|
48
|
-
cortex_llm-1.0.
|
|
49
|
-
cortex_llm-1.0.
|
|
50
|
+
cortex_llm-1.0.10.dist-info/licenses/LICENSE,sha256=_frJ3VsZWQGhMznZw2Tgjk7xwfAfDZRcBl43uZh8_4E,1070
|
|
51
|
+
cortex_llm-1.0.10.dist-info/METADATA,sha256=-z8lVLdrQFFLxDzMIwhTwRQwPzvRznRSI9ftTAFqjbE,5578
|
|
52
|
+
cortex_llm-1.0.10.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
53
|
+
cortex_llm-1.0.10.dist-info/entry_points.txt,sha256=g83Nuz3iFrNdMLHxGLR2LnscdM7rdQRchuL3WGobQC8,48
|
|
54
|
+
cortex_llm-1.0.10.dist-info/top_level.txt,sha256=79LAeTJJ_pMIBy3mkF7uNaN0mdBRt5tGrnne5N_iAio,7
|
|
55
|
+
cortex_llm-1.0.10.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|