pari-cli 0.1.3__tar.gz → 0.1.4__tar.gz
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.
- pari_cli-0.1.4/PKG-INFO +111 -0
- pari_cli-0.1.4/README.md +68 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/__init__.py +1 -1
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/agent/guards.py +2 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/agent/loop.py +18 -3
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/agent/runtime.py +48 -8
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/auth/account.py +7 -6
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/auth/interactive.py +62 -2
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/auth/login.py +49 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/auth/menu.py +2 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/cli.py +108 -21
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/edit/exact.py +5 -1
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/providers/__init__.py +4 -4
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/providers/deepseek.py +1 -2
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/providers/openai_compat.py +22 -6
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/sandbox/e2b.py +1 -1
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tools/bash.py +10 -2
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tools/registry.py +10 -2
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tools/search.py +26 -2
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tui/app.py +83 -2
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tui/prompt.py +9 -1
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tui/slash.py +2 -6
- pari_cli-0.1.4/pari_cli.egg-info/PKG-INFO +111 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pyproject.toml +1 -1
- pari_cli-0.1.3/PKG-INFO +0 -175
- pari_cli-0.1.3/README.md +0 -132
- pari_cli-0.1.3/pari_cli.egg-info/PKG-INFO +0 -175
- {pari_cli-0.1.3 → pari_cli-0.1.4}/LICENSE +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/__main__.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/agent/__init__.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/agent/lint.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/agent/reflection.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/agent/subagent.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/auth/__init__.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/auth/providers.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/auth/store.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/config.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/context/__init__.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/context/cache.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/context/chunks.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/context/compaction.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/context/epoch.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/context/warmer.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/edit/__init__.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/edit/cascade.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/edit/diff.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/edit/dmp.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/edit/git_osr.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/edit/indenter.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/eval/__init__.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/eval/judge.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/eval/logging.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/eval/metrics.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/eval/runner.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/fs/__init__.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/fs/atomic.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/fs/path.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/fs/queue.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/intelligence/__init__.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/intelligence/ast.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/intelligence/cache.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/intelligence/ppr.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/intelligence/repomap.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/intelligence/search.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/output/__init__.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/output/parser.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/output/spooler.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/output/truncation.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/providers/anthropic.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/providers/base.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/providers/grok.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/providers/mock.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/providers/ollama.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/providers/openai.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/providers/openrouter.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/routing/__init__.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/routing/anti_verbosity.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/routing/classifier.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/routing/router.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/sandbox/__init__.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/sandbox/base.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/sandbox/bwrap.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/sandbox/dispatcher.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/sandbox/process.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/sandbox/seatbelt.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/session/__init__.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/session/checkpoint.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/session/dag.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/session/events.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/session/logger.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/session/undo.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tools/__init__.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tools/base.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tools/fs.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tools/output_reader.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tui/__init__.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tui/commands.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tui/completer.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tui/diff_view.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tui/keys.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tui/status.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tui/style.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari_cli.egg-info/SOURCES.txt +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari_cli.egg-info/dependency_links.txt +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari_cli.egg-info/entry_points.txt +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari_cli.egg-info/requires.txt +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/pari_cli.egg-info/top_level.txt +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.4}/setup.cfg +0 -0
pari_cli-0.1.4/PKG-INFO
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pari-cli
|
|
3
|
+
Version: 0.1.4
|
|
4
|
+
Summary: A resilient, sandbox-first terminal coding agent engineered for high pass rates and low token burn.
|
|
5
|
+
Author: PARI Contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://pari-cli.in
|
|
8
|
+
Project-URL: Documentation, https://pari-cli.in
|
|
9
|
+
Project-URL: Repository, https://github.com/MdTanwer/pari
|
|
10
|
+
Keywords: ai,coding-agent,terminal,llm,developer-tools
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Operating System :: MacOS
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: rich>=13.0.0
|
|
24
|
+
Requires-Dist: prompt_toolkit>=3.0.36
|
|
25
|
+
Requires-Dist: pydantic>=2.0.0
|
|
26
|
+
Requires-Dist: httpx>=0.24.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=7.4.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
30
|
+
Requires-Dist: ruff>=0.12.10; extra == "dev"
|
|
31
|
+
Requires-Dist: pre-commit>=4.3.0; extra == "dev"
|
|
32
|
+
Requires-Dist: pycodestyle>=2.12.0; extra == "dev"
|
|
33
|
+
Provides-Extra: ast
|
|
34
|
+
Requires-Dist: tree-sitter>=0.21.0; extra == "ast"
|
|
35
|
+
Requires-Dist: tree-sitter-languages>=1.10.0; extra == "ast"
|
|
36
|
+
Requires-Dist: networkx>=3.0; extra == "ast"
|
|
37
|
+
Requires-Dist: grep-ast>=0.3.0; extra == "ast"
|
|
38
|
+
Provides-Extra: cloud
|
|
39
|
+
Requires-Dist: e2b>=0.14.0; extra == "cloud"
|
|
40
|
+
Provides-Extra: all
|
|
41
|
+
Requires-Dist: pari-cli[ast,cloud,dev]; extra == "all"
|
|
42
|
+
Dynamic: license-file
|
|
43
|
+
|
|
44
|
+
# PARI — Next-Generation Terminal Coding Agent
|
|
45
|
+
|
|
46
|
+
[](LICENSE)
|
|
47
|
+
[](https://www.python.org/downloads/)
|
|
48
|
+
[](docs/architecture/overview.md)
|
|
49
|
+
[](docs/layers/layer-0-sandboxing.md)
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## ⚡ The Core Thesis
|
|
54
|
+
|
|
55
|
+
$$\text{\bf Agent failure is a systems engineering failure, not a reasoning failure.}$$
|
|
56
|
+
|
|
57
|
+
The exact same frontier reasoning model (Claude 3.7 Sonnet, GPT-4o) demonstrates a **15% pass rate** in a naive loop, but achieves a **50–55% pass rate** inside an engineered harness. The 35% delta is not model intelligence; it is accounted for by **six fatal systems failure modes**:
|
|
58
|
+
|
|
59
|
+
1. **Unbounded stdout poisoning** $\rightarrow$ Solved by Layer 7 (50KB/2k-line disk spooler + balanced head/tail extraction).
|
|
60
|
+
2. **Mid-stream token truncation** $\rightarrow$ Solved by Layer 4 (Strict `stop_reason == "length"` rejection guard).
|
|
61
|
+
3. **File write race conditions** $\rightarrow$ Solved by Layer 6 (`FileMutationQueue` canonical realpath serialization).
|
|
62
|
+
4. **KV cache invalidation cascades** $\rightarrow$ Solved by Layer 2 (Immutable Baseline Epochs freezing Tiers 1–4).
|
|
63
|
+
5. **Blind file traversal loops** $\rightarrow$ Solved by Layer 3 (v1: Fast substring ripgrep & globbing / v2: Tree-sitter AST fallback).
|
|
64
|
+
6. **Unsandboxed host execution** $\rightarrow$ Solved by Layer 0 (Multi-platform kernel isolation: `bwrap`, `Seatbelt`, E2B MicroVMs).
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## 🚀 Installation & Quick Start
|
|
69
|
+
|
|
70
|
+
Official Website & Documentation: **[https://pari-cli.in](https://pari-cli.in)**
|
|
71
|
+
|
|
72
|
+
Choose your platform's installation command:
|
|
73
|
+
|
|
74
|
+
### 1. macOS & Linux (`curl`)
|
|
75
|
+
```bash
|
|
76
|
+
curl -fsSL https://pari-cli.in/install.sh | bash
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### 2. Windows (`irm` in PowerShell)
|
|
80
|
+
```powershell
|
|
81
|
+
irm https://www.pari-cli.in/install.ps1 | iex
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 3. Via `pip` / `pipx` / `uv`
|
|
85
|
+
```bash
|
|
86
|
+
# Recommended (isolated CLI)
|
|
87
|
+
pipx install pari-cli
|
|
88
|
+
|
|
89
|
+
# Fast via uv
|
|
90
|
+
uv tool install pari-cli
|
|
91
|
+
|
|
92
|
+
# Standard pip
|
|
93
|
+
pip install pari-cli
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
### 💻 Launch Interactive Session
|
|
99
|
+
```bash
|
|
100
|
+
# Export your API key
|
|
101
|
+
export ANTHROPIC_API_KEY="sk-ant-..."
|
|
102
|
+
|
|
103
|
+
# Launch PARI anywhere in your project workspace
|
|
104
|
+
pari-cli
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## ⚖️ License
|
|
110
|
+
|
|
111
|
+
MIT License. See [LICENSE](LICENSE) for details.
|
pari_cli-0.1.4/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# PARI — Next-Generation Terminal Coding Agent
|
|
2
|
+
|
|
3
|
+
[](LICENSE)
|
|
4
|
+
[](https://www.python.org/downloads/)
|
|
5
|
+
[](docs/architecture/overview.md)
|
|
6
|
+
[](docs/layers/layer-0-sandboxing.md)
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## ⚡ The Core Thesis
|
|
11
|
+
|
|
12
|
+
$$\text{\bf Agent failure is a systems engineering failure, not a reasoning failure.}$$
|
|
13
|
+
|
|
14
|
+
The exact same frontier reasoning model (Claude 3.7 Sonnet, GPT-4o) demonstrates a **15% pass rate** in a naive loop, but achieves a **50–55% pass rate** inside an engineered harness. The 35% delta is not model intelligence; it is accounted for by **six fatal systems failure modes**:
|
|
15
|
+
|
|
16
|
+
1. **Unbounded stdout poisoning** $\rightarrow$ Solved by Layer 7 (50KB/2k-line disk spooler + balanced head/tail extraction).
|
|
17
|
+
2. **Mid-stream token truncation** $\rightarrow$ Solved by Layer 4 (Strict `stop_reason == "length"` rejection guard).
|
|
18
|
+
3. **File write race conditions** $\rightarrow$ Solved by Layer 6 (`FileMutationQueue` canonical realpath serialization).
|
|
19
|
+
4. **KV cache invalidation cascades** $\rightarrow$ Solved by Layer 2 (Immutable Baseline Epochs freezing Tiers 1–4).
|
|
20
|
+
5. **Blind file traversal loops** $\rightarrow$ Solved by Layer 3 (v1: Fast substring ripgrep & globbing / v2: Tree-sitter AST fallback).
|
|
21
|
+
6. **Unsandboxed host execution** $\rightarrow$ Solved by Layer 0 (Multi-platform kernel isolation: `bwrap`, `Seatbelt`, E2B MicroVMs).
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## 🚀 Installation & Quick Start
|
|
26
|
+
|
|
27
|
+
Official Website & Documentation: **[https://pari-cli.in](https://pari-cli.in)**
|
|
28
|
+
|
|
29
|
+
Choose your platform's installation command:
|
|
30
|
+
|
|
31
|
+
### 1. macOS & Linux (`curl`)
|
|
32
|
+
```bash
|
|
33
|
+
curl -fsSL https://pari-cli.in/install.sh | bash
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### 2. Windows (`irm` in PowerShell)
|
|
37
|
+
```powershell
|
|
38
|
+
irm https://www.pari-cli.in/install.ps1 | iex
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### 3. Via `pip` / `pipx` / `uv`
|
|
42
|
+
```bash
|
|
43
|
+
# Recommended (isolated CLI)
|
|
44
|
+
pipx install pari-cli
|
|
45
|
+
|
|
46
|
+
# Fast via uv
|
|
47
|
+
uv tool install pari-cli
|
|
48
|
+
|
|
49
|
+
# Standard pip
|
|
50
|
+
pip install pari-cli
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
### 💻 Launch Interactive Session
|
|
56
|
+
```bash
|
|
57
|
+
# Export your API key
|
|
58
|
+
export ANTHROPIC_API_KEY="sk-ant-..."
|
|
59
|
+
|
|
60
|
+
# Launch PARI anywhere in your project workspace
|
|
61
|
+
pari-cli
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## ⚖️ License
|
|
67
|
+
|
|
68
|
+
MIT License. See [LICENSE](LICENSE) for details.
|
|
@@ -5,7 +5,9 @@ The loop itself decides when to stop. These values are the limits it checks.
|
|
|
5
5
|
|
|
6
6
|
MAX_TURNS = 40
|
|
7
7
|
TURN_WARNING = 30
|
|
8
|
+
TURN_EXTENSION = 10
|
|
8
9
|
DAEMON_IDLE_SECONDS = 10
|
|
10
|
+
TURN_LIMIT_PROMPT = "[Turn limit reached. Continue (+10 turns) | Revert | Stop]?"
|
|
9
11
|
|
|
10
12
|
# Shown when stop_reason is "length". The loop must drop the pending tool
|
|
11
13
|
# calls before it appends this notice. A truncated edit must not touch disk.
|
|
@@ -17,6 +17,7 @@ from pari.agent.guards import (
|
|
|
17
17
|
DAEMON_IDLE_SECONDS,
|
|
18
18
|
LENGTH_TRUNCATION_NOTICE,
|
|
19
19
|
MAX_TURNS,
|
|
20
|
+
TURN_LIMIT_PROMPT,
|
|
20
21
|
TURN_WARNING,
|
|
21
22
|
format_daemon_notice,
|
|
22
23
|
is_length_stop,
|
|
@@ -49,7 +50,6 @@ class TurnResult:
|
|
|
49
50
|
cached_tokens: int = 0
|
|
50
51
|
|
|
51
52
|
|
|
52
|
-
_TURN_LIMIT = "[Turn limit reached. Continue (+10 turns) | Revert | Stop]?"
|
|
53
53
|
_FILE_TOOLS = frozenset({"write_file", "edit_file"})
|
|
54
54
|
|
|
55
55
|
|
|
@@ -66,6 +66,7 @@ class AgentExecutionLoop:
|
|
|
66
66
|
undo: GuardedUndoManager | None = None,
|
|
67
67
|
workspace: Path | None = None,
|
|
68
68
|
on_tool_call: Callable[[str, dict[str, Any]], None] | None = None,
|
|
69
|
+
on_tool_result: Callable[[ToolCall, str], None] | None = None,
|
|
69
70
|
on_diff: Callable[[str, str], None] | None = None,
|
|
70
71
|
on_warning: Callable[[str], None] | None = None,
|
|
71
72
|
):
|
|
@@ -76,6 +77,7 @@ class AgentExecutionLoop:
|
|
|
76
77
|
self.undo = undo
|
|
77
78
|
self.workspace = workspace
|
|
78
79
|
self.on_tool_call = on_tool_call
|
|
80
|
+
self.on_tool_result = on_tool_result
|
|
79
81
|
self.on_diff = on_diff
|
|
80
82
|
self.on_warning = on_warning
|
|
81
83
|
self._warning = ""
|
|
@@ -140,19 +142,21 @@ class AgentExecutionLoop:
|
|
|
140
142
|
return finish(halt, executed, True)
|
|
141
143
|
history = _append(history, response_text)
|
|
142
144
|
pending = feedback
|
|
143
|
-
return finish(
|
|
145
|
+
return finish(TURN_LIMIT_PROMPT, executed, True, capture_diff=True)
|
|
144
146
|
|
|
145
147
|
async def _run_calls(
|
|
146
148
|
self, calls: list[ToolCall], executed: int
|
|
147
149
|
) -> tuple[str, int, str | None]:
|
|
148
150
|
"""Run the calls in order. A failure stops the rest of this batch."""
|
|
149
151
|
notes: list[str] = []
|
|
150
|
-
for call in calls:
|
|
152
|
+
for index, call in enumerate(calls):
|
|
151
153
|
path = _file_path(call)
|
|
152
154
|
before = self._read_rel(path) if path is not None else ""
|
|
153
155
|
try:
|
|
154
156
|
text = await self._execute_call(call)
|
|
155
157
|
except Exception as exc:
|
|
158
|
+
self._report_result(call, f"{type(exc).__name__}: {exc}")
|
|
159
|
+
self._skip_rest(calls[index + 1 :])
|
|
156
160
|
return self._fail(type(exc).__name__, str(exc), executed)
|
|
157
161
|
executed += 1
|
|
158
162
|
if path is not None:
|
|
@@ -160,7 +164,10 @@ class AgentExecutionLoop:
|
|
|
160
164
|
self._snapshot(path, before, after)
|
|
161
165
|
lint = lint_python(path, after)
|
|
162
166
|
if lint is not None:
|
|
167
|
+
self._report_result(call, lint)
|
|
168
|
+
self._skip_rest(calls[index + 1 :])
|
|
163
169
|
return self._fail("LINT ERROR", lint, executed)
|
|
170
|
+
self._report_result(call, text)
|
|
164
171
|
notes.append(text)
|
|
165
172
|
return "\n".join(notes), executed, None
|
|
166
173
|
|
|
@@ -179,6 +186,14 @@ class AgentExecutionLoop:
|
|
|
179
186
|
self.logger.append_event("TOOL_RESULT", {"tool": call.name, "output": text})
|
|
180
187
|
return text
|
|
181
188
|
|
|
189
|
+
def _report_result(self, call: ToolCall, text: str) -> None:
|
|
190
|
+
if self.on_tool_result is not None:
|
|
191
|
+
self.on_tool_result(call, text)
|
|
192
|
+
|
|
193
|
+
def _skip_rest(self, calls: list[ToolCall]) -> None:
|
|
194
|
+
for call in calls:
|
|
195
|
+
self._report_result(call, "Skipped because an earlier call failed.")
|
|
196
|
+
|
|
182
197
|
def _log_assistant(self, text: str) -> None:
|
|
183
198
|
if self.logger is None:
|
|
184
199
|
return
|
|
@@ -14,12 +14,17 @@ from collections.abc import Callable
|
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
from typing import Any
|
|
16
16
|
|
|
17
|
-
from pari.agent.guards import
|
|
18
|
-
|
|
17
|
+
from pari.agent.guards import (
|
|
18
|
+
MAX_TURNS,
|
|
19
|
+
TURN_EXTENSION,
|
|
20
|
+
TURN_LIMIT_PROMPT,
|
|
21
|
+
is_length_stop,
|
|
22
|
+
)
|
|
23
|
+
from pari.agent.loop import AgentExecutionLoop, TurnResult
|
|
19
24
|
from pari.agent.reflection import ReflectionManager
|
|
20
25
|
from pari.intelligence.repomap import RepoMapGenerator
|
|
21
26
|
from pari.providers import get_provider
|
|
22
|
-
from pari.providers.base import LLMProvider, ModelResponse, ProviderMessage
|
|
27
|
+
from pari.providers.base import LLMProvider, ModelResponse, ProviderMessage, ToolCall
|
|
23
28
|
from pari.routing.router import DualModelRouter, router_for
|
|
24
29
|
from pari.session.events import SessionEventType
|
|
25
30
|
from pari.session.logger import SessionLogger
|
|
@@ -39,6 +44,7 @@ class AgentRuntime:
|
|
|
39
44
|
edit_fallback: str = "none",
|
|
40
45
|
repomap: str = "off",
|
|
41
46
|
router: DualModelRouter | None = None,
|
|
47
|
+
sandbox_mode: str | None = None,
|
|
42
48
|
):
|
|
43
49
|
self.workspace = workspace.resolve()
|
|
44
50
|
self.session_file = session_file or (self.workspace / ".pari" / "session.jsonl")
|
|
@@ -51,7 +57,14 @@ class AgentRuntime:
|
|
|
51
57
|
self._owns_router = router is None
|
|
52
58
|
self.router = router if router is not None else router_for(self.provider)
|
|
53
59
|
self.undo_manager = GuardedUndoManager(self.workspace)
|
|
54
|
-
self.tools = get_default_registry(
|
|
60
|
+
self.tools = get_default_registry(
|
|
61
|
+
self.workspace,
|
|
62
|
+
edit_fallback=edit_fallback,
|
|
63
|
+
sandbox_mode=sandbox_mode,
|
|
64
|
+
)
|
|
65
|
+
self.awaiting_limit = False
|
|
66
|
+
self.last_result: TurnResult | None = None
|
|
67
|
+
self._tool_followup = False
|
|
55
68
|
self.pinned_files: list[str] = []
|
|
56
69
|
self.session_id: str | None = None
|
|
57
70
|
self.messages: list[ProviderMessage] = []
|
|
@@ -110,23 +123,33 @@ class AgentRuntime:
|
|
|
110
123
|
def add_pinned_file(self, rel_path: str) -> bool:
|
|
111
124
|
"""Pin reference file into active context."""
|
|
112
125
|
file_path = (self.workspace / rel_path).resolve()
|
|
113
|
-
if not file_path.
|
|
126
|
+
if not file_path.is_file():
|
|
127
|
+
return False
|
|
128
|
+
try:
|
|
129
|
+
clean_rel = str(file_path.relative_to(self.workspace))
|
|
130
|
+
except ValueError:
|
|
114
131
|
return False
|
|
115
|
-
clean_rel = str(file_path.relative_to(self.workspace))
|
|
116
132
|
if clean_rel not in self.pinned_files:
|
|
117
133
|
self.pinned_files.append(clean_rel)
|
|
118
134
|
return True
|
|
119
135
|
|
|
136
|
+
async def continue_turn(self) -> str:
|
|
137
|
+
"""Run another block of turns after the user chooses Continue."""
|
|
138
|
+
return await self.execute_turn("Continue.", max_turns=TURN_EXTENSION)
|
|
139
|
+
|
|
120
140
|
async def execute_turn(
|
|
121
141
|
self,
|
|
122
142
|
prompt: str,
|
|
123
143
|
on_tool_call: Callable[[str, dict[str, Any]], None] | None = None,
|
|
124
144
|
on_diff: Callable[[str, str], None] | None = None,
|
|
145
|
+
*,
|
|
146
|
+
max_turns: int | None = None,
|
|
125
147
|
) -> str:
|
|
126
148
|
"""Execute conversational turn with autonomous tool execution."""
|
|
127
149
|
self.on_tool_call = on_tool_call
|
|
128
150
|
self.on_diff = on_diff
|
|
129
151
|
self.turn_warning = ""
|
|
152
|
+
self._tool_followup = False
|
|
130
153
|
if not self.session_id:
|
|
131
154
|
self.start_session()
|
|
132
155
|
self.router.begin_turn()
|
|
@@ -134,7 +157,11 @@ class AgentRuntime:
|
|
|
134
157
|
schemas = self.tools.get_schemas()
|
|
135
158
|
|
|
136
159
|
async def step(context: str, _turn: int) -> ModelResponse:
|
|
137
|
-
|
|
160
|
+
# After a tool call, the results are already on the transcript.
|
|
161
|
+
# A new user message here would hide them from the model.
|
|
162
|
+
if not self._tool_followup:
|
|
163
|
+
self.messages.append(ProviderMessage(role="user", content=context))
|
|
164
|
+
self._tool_followup = False
|
|
138
165
|
# Wide prompts plan once, with no tools. The editor runs the calls.
|
|
139
166
|
resp = await self.router.complete(
|
|
140
167
|
self.messages,
|
|
@@ -143,7 +170,11 @@ class AgentRuntime:
|
|
|
143
170
|
active_files=self.pinned_files,
|
|
144
171
|
)
|
|
145
172
|
# Drop tool calls that were cut off so the next step cannot rerun them.
|
|
146
|
-
calls = [] if is_length_stop(resp.stop_reason) else resp.tool_calls
|
|
173
|
+
calls = [] if is_length_stop(resp.stop_reason) else list(resp.tool_calls)
|
|
174
|
+
for index, call in enumerate(calls):
|
|
175
|
+
if not call.id:
|
|
176
|
+
call.id = f"call_{index}"
|
|
177
|
+
self._tool_followup = bool(calls)
|
|
147
178
|
self.messages.append(
|
|
148
179
|
ProviderMessage(
|
|
149
180
|
role="assistant",
|
|
@@ -153,18 +184,25 @@ class AgentRuntime:
|
|
|
153
184
|
)
|
|
154
185
|
return resp
|
|
155
186
|
|
|
187
|
+
def remember_result(call: ToolCall, text: str) -> None:
|
|
188
|
+
self.messages.append(
|
|
189
|
+
ProviderMessage(role="tool", content=text, tool_call_id=call.id)
|
|
190
|
+
)
|
|
191
|
+
|
|
156
192
|
dag = self.workspace / ".pari" / "sessions" / f"{self.session_id}.jsonl"
|
|
157
193
|
|
|
158
194
|
def remember_warning(text: str) -> None:
|
|
159
195
|
self.turn_warning = text
|
|
160
196
|
|
|
161
197
|
loop = AgentExecutionLoop(
|
|
198
|
+
max_turns if max_turns is not None else MAX_TURNS,
|
|
162
199
|
registry=self.tools,
|
|
163
200
|
reflection=ReflectionManager(),
|
|
164
201
|
logger=SessionLogger(dag),
|
|
165
202
|
undo=self.undo_manager,
|
|
166
203
|
workspace=self.workspace,
|
|
167
204
|
on_tool_call=on_tool_call,
|
|
205
|
+
on_tool_result=remember_result,
|
|
168
206
|
on_diff=on_diff,
|
|
169
207
|
on_warning=remember_warning,
|
|
170
208
|
)
|
|
@@ -189,6 +227,8 @@ class AgentRuntime:
|
|
|
189
227
|
self.tokens_in = result.tokens_in
|
|
190
228
|
self.tokens_out = result.tokens_out
|
|
191
229
|
self.cached_tokens = result.cached_tokens
|
|
230
|
+
self.last_result = result
|
|
231
|
+
self.awaiting_limit = result.response_text == TURN_LIMIT_PROMPT
|
|
192
232
|
return result.response_text
|
|
193
233
|
|
|
194
234
|
def _repo_map(self) -> str:
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
"""Account sign-in for
|
|
1
|
+
"""Account sign-in for providers that have a browser or device login.
|
|
2
2
|
|
|
3
3
|
OpenAI, Anthropic, and OpenRouter open a browser and accept the redirect URL
|
|
4
4
|
if the local callback does not finish. Grok uses a device code. DeepSeek has
|
|
5
|
-
no
|
|
5
|
+
no account login, so that path does not ask for a token. Radius is not a
|
|
6
|
+
provider.
|
|
6
7
|
"""
|
|
7
8
|
|
|
8
9
|
import base64
|
|
@@ -89,10 +90,10 @@ def sign_in_account(provider_id: str, ui: AccountUI, store: AuthStore) -> str:
|
|
|
89
90
|
"""Run that provider's account login and store the credential."""
|
|
90
91
|
info = get_info(provider_id)
|
|
91
92
|
if info.id == "deepseek":
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
raise ValueError(
|
|
94
|
+
"DeepSeek has no account sign-in. "
|
|
95
|
+
"Use pari-cli login deepseek --api-key <key>."
|
|
96
|
+
)
|
|
96
97
|
if info.id == "openai":
|
|
97
98
|
cred = _login_openai(ui, store)
|
|
98
99
|
elif info.id == "anthropic":
|
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
"""``pari-cli login`` screens: method, provider, then the sign-in itself."""
|
|
2
2
|
|
|
3
3
|
import base64
|
|
4
|
+
import ctypes
|
|
4
5
|
import getpass
|
|
5
6
|
import os
|
|
6
7
|
import select
|
|
7
8
|
import sys
|
|
8
|
-
import termios
|
|
9
9
|
import time
|
|
10
|
-
import tty
|
|
11
10
|
import webbrowser
|
|
12
11
|
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import msvcrt
|
|
15
|
+
except ImportError:
|
|
16
|
+
msvcrt = None
|
|
17
|
+
try:
|
|
18
|
+
import termios
|
|
19
|
+
import tty
|
|
20
|
+
except ImportError:
|
|
21
|
+
termios = None
|
|
22
|
+
tty = None
|
|
23
|
+
|
|
13
24
|
from pari.auth.account import AccountUI, LoginCancelled, sign_in_account
|
|
14
25
|
from pari.auth.login import sign_in, sign_out, status_text
|
|
15
26
|
from pari.auth.menu import (
|
|
@@ -128,6 +139,12 @@ def _finish(kind: str, provider_id: str, ui: AccountUI, store: AuthStore) -> str
|
|
|
128
139
|
try:
|
|
129
140
|
if kind == "api_key":
|
|
130
141
|
return _read_api_key(ui, info, store)
|
|
142
|
+
if info.id == "deepseek":
|
|
143
|
+
ui.show_error(
|
|
144
|
+
"DeepSeek has no account sign-in. "
|
|
145
|
+
"Use pari-cli login deepseek --api-key <key>."
|
|
146
|
+
)
|
|
147
|
+
return None
|
|
131
148
|
return sign_in_account(info.id, ui, store)
|
|
132
149
|
except LoginCancelled:
|
|
133
150
|
return None
|
|
@@ -279,6 +296,10 @@ class TerminalLogin:
|
|
|
279
296
|
if self._raw:
|
|
280
297
|
return
|
|
281
298
|
self._fd = sys.stdin.fileno()
|
|
299
|
+
if termios is None or tty is None:
|
|
300
|
+
_enable_windows_vt()
|
|
301
|
+
self._raw = True
|
|
302
|
+
return
|
|
282
303
|
current = termios.tcgetattr(self._fd)
|
|
283
304
|
self._term = _copy_mode(current)
|
|
284
305
|
# cbreak, not raw: raw turns off newline output, so each row is drawn
|
|
@@ -393,7 +414,17 @@ def read_menu_key(fd: int, pending: bytes, timeout: float | None) -> tuple[str,
|
|
|
393
414
|
return key, pending
|
|
394
415
|
|
|
395
416
|
|
|
417
|
+
_WIN_ARROWS = {
|
|
418
|
+
"H": b"\x1b[A",
|
|
419
|
+
"P": b"\x1b[B",
|
|
420
|
+
"K": b"\x1b[D",
|
|
421
|
+
"M": b"\x1b[C",
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
|
|
396
425
|
def _read_chunk(fd: int, timeout: float | None) -> bytes:
|
|
426
|
+
if msvcrt is not None:
|
|
427
|
+
return _read_windows(timeout)
|
|
397
428
|
if timeout is not None:
|
|
398
429
|
ready, _, _ = select.select([fd], [], [], timeout)
|
|
399
430
|
if not ready:
|
|
@@ -404,6 +435,35 @@ def _read_chunk(fd: int, timeout: float | None) -> bytes:
|
|
|
404
435
|
return b""
|
|
405
436
|
|
|
406
437
|
|
|
438
|
+
def _read_windows(timeout: float | None) -> bytes:
|
|
439
|
+
if timeout is not None and not _windows_ready(timeout):
|
|
440
|
+
return b""
|
|
441
|
+
char = msvcrt.getwch()
|
|
442
|
+
if char in ("\x00", "\xe0"):
|
|
443
|
+
return _WIN_ARROWS.get(msvcrt.getwch(), b"")
|
|
444
|
+
return char.encode("utf-8")
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _windows_ready(timeout: float) -> bool:
|
|
448
|
+
deadline = time.monotonic() + timeout
|
|
449
|
+
while not msvcrt.kbhit():
|
|
450
|
+
if time.monotonic() >= deadline:
|
|
451
|
+
return False
|
|
452
|
+
time.sleep(0.01)
|
|
453
|
+
return True
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _enable_windows_vt() -> None:
|
|
457
|
+
if msvcrt is None:
|
|
458
|
+
return
|
|
459
|
+
kernel = ctypes.windll.kernel32
|
|
460
|
+
handle = kernel.GetStdHandle(-11)
|
|
461
|
+
mode = ctypes.c_uint()
|
|
462
|
+
if not kernel.GetConsoleMode(handle, ctypes.byref(mode)):
|
|
463
|
+
return
|
|
464
|
+
kernel.SetConsoleMode(handle, mode.value | 0x0004)
|
|
465
|
+
|
|
466
|
+
|
|
407
467
|
def _split_sequence(buf: bytes) -> tuple[bytes | None, bytes]:
|
|
408
468
|
"""Split one key from ``buf``. None means an unfinished Escape sequence."""
|
|
409
469
|
if not buf:
|
|
@@ -35,6 +35,11 @@ def sign_in(
|
|
|
35
35
|
if method not in ("api_key", "account"):
|
|
36
36
|
raise ValueError("Sign in with an API key or an account token.")
|
|
37
37
|
info = get_info(provider_id)
|
|
38
|
+
if method == "account" and info.id == "deepseek":
|
|
39
|
+
raise ValueError(
|
|
40
|
+
"DeepSeek has no account sign-in. "
|
|
41
|
+
"Use pari-cli login deepseek --api-key <key>."
|
|
42
|
+
)
|
|
38
43
|
token = secret.strip()
|
|
39
44
|
if not token:
|
|
40
45
|
raise ValueError("The credential is empty.")
|
|
@@ -50,6 +55,8 @@ def sign_in(
|
|
|
50
55
|
expires = (extra or {}).get("expires")
|
|
51
56
|
if isinstance(expires, int):
|
|
52
57
|
row["expires"] = expires
|
|
58
|
+
# Latest sign-in is the session default, so it has to be last.
|
|
59
|
+
data.pop(info.id, None)
|
|
53
60
|
data[info.id] = row
|
|
54
61
|
auth.save(data)
|
|
55
62
|
label = "API key" if method == "api_key" else "account"
|
|
@@ -142,7 +149,49 @@ def _now_ms() -> int:
|
|
|
142
149
|
return int(time.time() * 1000)
|
|
143
150
|
|
|
144
151
|
|
|
152
|
+
NO_MODEL = (
|
|
153
|
+
"No model selected. Sign in with `pari-cli login`, "
|
|
154
|
+
"or pass --model, or set PARI_MODEL."
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def default_model_name(store: AuthStore | None = None) -> str | None:
|
|
159
|
+
"""Model used when the CLI was started without ``--model``.
|
|
160
|
+
|
|
161
|
+
``PARI_MODEL`` wins. Otherwise the latest saved sign-in is used, then an
|
|
162
|
+
API key already in the environment. Nothing is chosen when no credential
|
|
163
|
+
is available.
|
|
164
|
+
"""
|
|
165
|
+
chosen = os.environ.get("PARI_MODEL", "").strip()
|
|
166
|
+
if chosen:
|
|
167
|
+
return chosen
|
|
168
|
+
saved = _saved_provider_ids((store or AuthStore()).load())
|
|
169
|
+
if saved:
|
|
170
|
+
return get_info(saved[-1]).default_model
|
|
171
|
+
for info in PROVIDERS:
|
|
172
|
+
if os.environ.get(info.env, "").strip():
|
|
173
|
+
return info.default_model
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _saved_provider_ids(data: dict) -> list[str]:
|
|
178
|
+
ids: list[str] = []
|
|
179
|
+
for key, row in data.items():
|
|
180
|
+
if not isinstance(key, str) or not isinstance(row, dict):
|
|
181
|
+
continue
|
|
182
|
+
secret = row.get("secret")
|
|
183
|
+
if not isinstance(secret, str) or not secret.strip():
|
|
184
|
+
continue
|
|
185
|
+
try:
|
|
186
|
+
ids.append(get_info(key).id)
|
|
187
|
+
except ValueError:
|
|
188
|
+
continue
|
|
189
|
+
return ids
|
|
190
|
+
|
|
191
|
+
|
|
145
192
|
def sign_in_hint(info: ProviderInfo) -> str:
|
|
193
|
+
if info.id == "deepseek":
|
|
194
|
+
return "Sign in to DeepSeek: pari-cli login deepseek --api-key <key>."
|
|
146
195
|
return (
|
|
147
196
|
f"Sign in to {info.label}: "
|
|
148
197
|
f"pari-cli login {info.id} --api-key <key> "
|
|
@@ -30,6 +30,8 @@ def provider_rows(method: str, store: AuthStore | None = None) -> list[ProviderC
|
|
|
30
30
|
"""Providers in name order, each with a status that does not include the secret."""
|
|
31
31
|
data = (store or AuthStore()).load()
|
|
32
32
|
infos = sorted(PROVIDERS, key=lambda item: item.label.lower())
|
|
33
|
+
if method == "account":
|
|
34
|
+
infos = [info for info in infos if info.id != "deepseek"]
|
|
33
35
|
return [
|
|
34
36
|
ProviderChoice(info.id, info.label, _status(info, method, data))
|
|
35
37
|
for info in infos
|