pari-cli 0.1.3__tar.gz → 0.1.6__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.
Files changed (131) hide show
  1. pari_cli-0.1.6/PKG-INFO +111 -0
  2. pari_cli-0.1.6/README.md +68 -0
  3. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/__init__.py +1 -1
  4. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/agent/guards.py +2 -0
  5. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/agent/loop.py +20 -4
  6. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/agent/runtime.py +98 -15
  7. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/agent/subagent.py +1 -2
  8. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/auth/account.py +23 -24
  9. pari_cli-0.1.6/pari/auth/credential.py +128 -0
  10. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/auth/interactive.py +108 -18
  11. pari_cli-0.1.6/pari/auth/login.py +149 -0
  12. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/auth/menu.py +18 -11
  13. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/auth/providers.py +15 -0
  14. pari_cli-0.1.6/pari/auth/secret_field.py +50 -0
  15. pari_cli-0.1.6/pari/auth/store.py +62 -0
  16. pari_cli-0.1.6/pari/auth/verify.py +55 -0
  17. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/cli.py +115 -28
  18. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/edit/exact.py +5 -1
  19. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/eval/judge.py +2 -1
  20. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/eval/logging.py +2 -1
  21. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/eval/metrics.py +2 -1
  22. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/eval/runner.py +2 -1
  23. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/fs/__init__.py +2 -1
  24. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/fs/atomic.py +21 -2
  25. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/intelligence/repomap.py +1 -14
  26. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/providers/__init__.py +20 -10
  27. pari_cli-0.1.6/pari/providers/anthropic.py +83 -0
  28. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/providers/base.py +2 -30
  29. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/providers/deepseek.py +2 -2
  30. pari_cli-0.1.6/pari/providers/exceptions/__init__.py +26 -0
  31. pari_cli-0.1.6/pari/providers/exceptions/classifier.py +60 -0
  32. pari_cli-0.1.6/pari/providers/exceptions/mapping.py +90 -0
  33. pari_cli-0.1.6/pari/providers/exceptions/types.py +40 -0
  34. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/providers/grok.py +1 -0
  35. pari_cli-0.1.6/pari/providers/http.py +99 -0
  36. pari_cli-0.1.6/pari/providers/message.py +31 -0
  37. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/providers/mock.py +15 -13
  38. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/providers/ollama.py +7 -2
  39. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/providers/openai.py +1 -0
  40. pari_cli-0.1.6/pari/providers/openai_compat.py +79 -0
  41. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/providers/openrouter.py +1 -0
  42. pari_cli-0.1.6/pari/providers/response.py +33 -0
  43. pari_cli-0.1.6/pari/providers/schemas/__init__.py +42 -0
  44. pari_cli-0.1.6/pari/providers/schemas/anthropic.py +207 -0
  45. pari_cli-0.1.6/pari/providers/schemas/openai.py +259 -0
  46. pari_cli-0.1.6/pari/providers/schemas/types.py +17 -0
  47. pari_cli-0.1.6/pari/providers/stream.py +214 -0
  48. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/routing/router.py +10 -2
  49. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/sandbox/base.py +77 -16
  50. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/sandbox/bwrap.py +3 -0
  51. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/sandbox/e2b.py +5 -4
  52. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/sandbox/process.py +3 -0
  53. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/sandbox/seatbelt.py +4 -4
  54. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tools/bash.py +14 -3
  55. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tools/fs.py +81 -17
  56. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tools/registry.py +10 -2
  57. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tools/search.py +26 -2
  58. pari_cli-0.1.6/pari/tui/activity.py +230 -0
  59. pari_cli-0.1.6/pari/tui/app.py +494 -0
  60. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tui/completer.py +27 -0
  61. pari_cli-0.1.6/pari/tui/errors.py +22 -0
  62. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tui/keys.py +34 -3
  63. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tui/prompt.py +9 -1
  64. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tui/slash.py +2 -6
  65. pari_cli-0.1.6/pari/tui/transcript.py +44 -0
  66. pari_cli-0.1.6/pari_cli.egg-info/PKG-INFO +111 -0
  67. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari_cli.egg-info/SOURCES.txt +18 -0
  68. {pari_cli-0.1.3 → pari_cli-0.1.6}/pyproject.toml +2 -2
  69. pari_cli-0.1.3/PKG-INFO +0 -175
  70. pari_cli-0.1.3/README.md +0 -132
  71. pari_cli-0.1.3/pari/auth/login.py +0 -158
  72. pari_cli-0.1.3/pari/auth/store.py +0 -43
  73. pari_cli-0.1.3/pari/providers/anthropic.py +0 -167
  74. pari_cli-0.1.3/pari/providers/openai_compat.py +0 -141
  75. pari_cli-0.1.3/pari/tui/app.py +0 -157
  76. pari_cli-0.1.3/pari_cli.egg-info/PKG-INFO +0 -175
  77. {pari_cli-0.1.3 → pari_cli-0.1.6}/LICENSE +0 -0
  78. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/__main__.py +0 -0
  79. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/agent/__init__.py +0 -0
  80. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/agent/lint.py +0 -0
  81. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/agent/reflection.py +0 -0
  82. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/auth/__init__.py +0 -0
  83. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/config.py +0 -0
  84. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/context/__init__.py +0 -0
  85. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/context/cache.py +0 -0
  86. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/context/chunks.py +0 -0
  87. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/context/compaction.py +0 -0
  88. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/context/epoch.py +0 -0
  89. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/context/warmer.py +0 -0
  90. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/edit/__init__.py +0 -0
  91. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/edit/cascade.py +0 -0
  92. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/edit/diff.py +0 -0
  93. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/edit/dmp.py +0 -0
  94. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/edit/git_osr.py +0 -0
  95. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/edit/indenter.py +0 -0
  96. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/eval/__init__.py +0 -0
  97. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/fs/path.py +0 -0
  98. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/fs/queue.py +0 -0
  99. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/intelligence/__init__.py +0 -0
  100. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/intelligence/ast.py +0 -0
  101. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/intelligence/cache.py +0 -0
  102. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/intelligence/ppr.py +0 -0
  103. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/intelligence/search.py +0 -0
  104. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/output/__init__.py +0 -0
  105. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/output/parser.py +0 -0
  106. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/output/spooler.py +0 -0
  107. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/output/truncation.py +0 -0
  108. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/routing/__init__.py +0 -0
  109. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/routing/anti_verbosity.py +0 -0
  110. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/routing/classifier.py +0 -0
  111. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/sandbox/__init__.py +0 -0
  112. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/sandbox/dispatcher.py +0 -0
  113. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/session/__init__.py +0 -0
  114. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/session/checkpoint.py +0 -0
  115. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/session/dag.py +0 -0
  116. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/session/events.py +0 -0
  117. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/session/logger.py +0 -0
  118. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/session/undo.py +0 -0
  119. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tools/__init__.py +0 -0
  120. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tools/base.py +0 -0
  121. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tools/output_reader.py +0 -0
  122. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tui/__init__.py +0 -0
  123. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tui/commands.py +0 -0
  124. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tui/diff_view.py +0 -0
  125. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tui/status.py +0 -0
  126. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tui/style.py +0 -0
  127. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari_cli.egg-info/dependency_links.txt +0 -0
  128. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari_cli.egg-info/entry_points.txt +0 -0
  129. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari_cli.egg-info/requires.txt +0 -0
  130. {pari_cli-0.1.3 → pari_cli-0.1.6}/pari_cli.egg-info/top_level.txt +0 -0
  131. {pari_cli-0.1.3 → pari_cli-0.1.6}/setup.cfg +0 -0
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: pari-cli
3
+ Version: 0.1.6
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/docs
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: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
47
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
48
+ [![Architecture: 11-Layer Stack](https://img.shields.io/badge/architecture-11--layer-green.svg)](docs/architecture/overview.md)
49
+ [![Security: Kernel Sandboxed](https://img.shields.io/badge/sandbox-bwrap%20%7C%20Seatbelt%20%7C%20E2B-orange.svg)](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/docs](https://pari-cli.in/docs)**
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.
@@ -0,0 +1,68 @@
1
+ # PARI — Next-Generation Terminal Coding Agent
2
+
3
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
4
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
5
+ [![Architecture: 11-Layer Stack](https://img.shields.io/badge/architecture-11--layer-green.svg)](docs/architecture/overview.md)
6
+ [![Security: Kernel Sandboxed](https://img.shields.io/badge/sandbox-bwrap%20%7C%20Seatbelt%20%7C%20E2B-orange.svg)](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/docs](https://pari-cli.in/docs)**
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.
@@ -1,3 +1,3 @@
1
1
  """PARI — High-performance terminal coding agent."""
2
2
 
3
- __version__ = "0.1.3"
3
+ __version__ = "0.1.6"
@@ -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,
@@ -27,7 +28,8 @@ from pari.context.chunks import ContextChunk, ContextTier, assemble_context
27
28
  from pari.context.compaction import compact_context
28
29
  from pari.edit.diff import generate_unified_diff
29
30
  from pari.fs.path import resolve_safe_path
30
- from pari.providers.base import ModelResponse, ToolCall
31
+ from pari.providers.message import ToolCall
32
+ from pari.providers.response import ModelResponse
31
33
  from pari.session.logger import SessionLogger
32
34
  from pari.session.undo import GuardedUndoManager
33
35
 
@@ -49,7 +51,6 @@ class TurnResult:
49
51
  cached_tokens: int = 0
50
52
 
51
53
 
52
- _TURN_LIMIT = "[Turn limit reached. Continue (+10 turns) | Revert | Stop]?"
53
54
  _FILE_TOOLS = frozenset({"write_file", "edit_file"})
54
55
 
55
56
 
@@ -66,6 +67,7 @@ class AgentExecutionLoop:
66
67
  undo: GuardedUndoManager | None = None,
67
68
  workspace: Path | None = None,
68
69
  on_tool_call: Callable[[str, dict[str, Any]], None] | None = None,
70
+ on_tool_result: Callable[[ToolCall, str], None] | None = None,
69
71
  on_diff: Callable[[str, str], None] | None = None,
70
72
  on_warning: Callable[[str], None] | None = None,
71
73
  ):
@@ -76,6 +78,7 @@ class AgentExecutionLoop:
76
78
  self.undo = undo
77
79
  self.workspace = workspace
78
80
  self.on_tool_call = on_tool_call
81
+ self.on_tool_result = on_tool_result
79
82
  self.on_diff = on_diff
80
83
  self.on_warning = on_warning
81
84
  self._warning = ""
@@ -140,19 +143,21 @@ class AgentExecutionLoop:
140
143
  return finish(halt, executed, True)
141
144
  history = _append(history, response_text)
142
145
  pending = feedback
143
- return finish(_TURN_LIMIT, executed, True, capture_diff=True)
146
+ return finish(TURN_LIMIT_PROMPT, executed, True, capture_diff=True)
144
147
 
145
148
  async def _run_calls(
146
149
  self, calls: list[ToolCall], executed: int
147
150
  ) -> tuple[str, int, str | None]:
148
151
  """Run the calls in order. A failure stops the rest of this batch."""
149
152
  notes: list[str] = []
150
- for call in calls:
153
+ for index, call in enumerate(calls):
151
154
  path = _file_path(call)
152
155
  before = self._read_rel(path) if path is not None else ""
153
156
  try:
154
157
  text = await self._execute_call(call)
155
158
  except Exception as exc:
159
+ self._report_result(call, f"{type(exc).__name__}: {exc}")
160
+ self._skip_rest(calls[index + 1 :])
156
161
  return self._fail(type(exc).__name__, str(exc), executed)
157
162
  executed += 1
158
163
  if path is not None:
@@ -160,7 +165,10 @@ class AgentExecutionLoop:
160
165
  self._snapshot(path, before, after)
161
166
  lint = lint_python(path, after)
162
167
  if lint is not None:
168
+ self._report_result(call, lint)
169
+ self._skip_rest(calls[index + 1 :])
163
170
  return self._fail("LINT ERROR", lint, executed)
171
+ self._report_result(call, text)
164
172
  notes.append(text)
165
173
  return "\n".join(notes), executed, None
166
174
 
@@ -179,6 +187,14 @@ class AgentExecutionLoop:
179
187
  self.logger.append_event("TOOL_RESULT", {"tool": call.name, "output": text})
180
188
  return text
181
189
 
190
+ def _report_result(self, call: ToolCall, text: str) -> None:
191
+ if self.on_tool_result is not None:
192
+ self.on_tool_result(call, text)
193
+
194
+ def _skip_rest(self, calls: list[ToolCall]) -> None:
195
+ for call in calls:
196
+ self._report_result(call, "Skipped because an earlier call failed.")
197
+
182
198
  def _log_assistant(self, text: str) -> None:
183
199
  if self.logger is None:
184
200
  return
@@ -14,12 +14,20 @@ 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 is_length_stop
18
- from pari.agent.loop import AgentExecutionLoop
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
28
+ from pari.providers.exceptions import ProviderError
29
+ from pari.providers.message import ProviderMessage, ToolCall
30
+ from pari.providers.response import ModelResponse
23
31
  from pari.routing.router import DualModelRouter, router_for
24
32
  from pari.session.events import SessionEventType
25
33
  from pari.session.logger import SessionLogger
@@ -39,6 +47,7 @@ class AgentRuntime:
39
47
  edit_fallback: str = "none",
40
48
  repomap: str = "off",
41
49
  router: DualModelRouter | None = None,
50
+ sandbox_mode: str | None = None,
42
51
  ):
43
52
  self.workspace = workspace.resolve()
44
53
  self.session_file = session_file or (self.workspace / ".pari" / "session.jsonl")
@@ -51,7 +60,14 @@ class AgentRuntime:
51
60
  self._owns_router = router is None
52
61
  self.router = router if router is not None else router_for(self.provider)
53
62
  self.undo_manager = GuardedUndoManager(self.workspace)
54
- self.tools = get_default_registry(self.workspace, edit_fallback=edit_fallback)
63
+ self.tools = get_default_registry(
64
+ self.workspace,
65
+ edit_fallback=edit_fallback,
66
+ sandbox_mode=sandbox_mode,
67
+ )
68
+ self.awaiting_limit = False
69
+ self.last_result: TurnResult | None = None
70
+ self._tool_followup = False
55
71
  self.pinned_files: list[str] = []
56
72
  self.session_id: str | None = None
57
73
  self.messages: list[ProviderMessage] = []
@@ -110,23 +126,51 @@ class AgentRuntime:
110
126
  def add_pinned_file(self, rel_path: str) -> bool:
111
127
  """Pin reference file into active context."""
112
128
  file_path = (self.workspace / rel_path).resolve()
113
- if not file_path.exists():
129
+ if not file_path.is_file():
130
+ return False
131
+ try:
132
+ clean_rel = str(file_path.relative_to(self.workspace))
133
+ except ValueError:
114
134
  return False
115
- clean_rel = str(file_path.relative_to(self.workspace))
116
135
  if clean_rel not in self.pinned_files:
117
136
  self.pinned_files.append(clean_rel)
118
137
  return True
119
138
 
139
+ async def continue_turn(
140
+ self,
141
+ on_tool_call: Callable[[str, dict[str, Any]], None] | None = None,
142
+ on_diff: Callable[[str, str], None] | None = None,
143
+ on_tool_result: Callable[[ToolCall, str], None] | None = None,
144
+ on_step_text: Callable[[str], None] | None = None,
145
+ on_delta: Callable[[str], None] | None = None,
146
+ ) -> str:
147
+ """Run another block of turns after the user chooses Continue."""
148
+ return await self.execute_turn(
149
+ "Continue.",
150
+ on_tool_call=on_tool_call,
151
+ on_diff=on_diff,
152
+ on_tool_result=on_tool_result,
153
+ on_step_text=on_step_text,
154
+ on_delta=on_delta,
155
+ max_turns=TURN_EXTENSION,
156
+ )
157
+
120
158
  async def execute_turn(
121
159
  self,
122
160
  prompt: str,
123
161
  on_tool_call: Callable[[str, dict[str, Any]], None] | None = None,
124
162
  on_diff: Callable[[str, str], None] | None = None,
163
+ on_tool_result: Callable[[ToolCall, str], None] | None = None,
164
+ on_step_text: Callable[[str], None] | None = None,
165
+ on_delta: Callable[[str], None] | None = None,
166
+ *,
167
+ max_turns: int | None = None,
125
168
  ) -> str:
126
169
  """Execute conversational turn with autonomous tool execution."""
127
170
  self.on_tool_call = on_tool_call
128
171
  self.on_diff = on_diff
129
172
  self.turn_warning = ""
173
+ self._tool_followup = False
130
174
  if not self.session_id:
131
175
  self.start_session()
132
176
  self.router.begin_turn()
@@ -134,16 +178,35 @@ class AgentRuntime:
134
178
  schemas = self.tools.get_schemas()
135
179
 
136
180
  async def step(context: str, _turn: int) -> ModelResponse:
137
- self.messages.append(ProviderMessage(role="user", content=context))
181
+ # After a tool call, the results are already on the transcript.
182
+ # A new user message here would hide them from the model.
183
+ if not self._tool_followup:
184
+ self.messages.append(ProviderMessage(role="user", content=context))
185
+ self._tool_followup = False
138
186
  # Wide prompts plan once, with no tools. The editor runs the calls.
139
187
  resp = await self.router.complete(
140
188
  self.messages,
141
189
  schemas,
142
190
  prompt=prompt,
143
191
  active_files=self.pinned_files,
192
+ on_delta=on_delta,
144
193
  )
145
194
  # 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
195
+ calls = [] if is_length_stop(resp.stop_reason) else list(resp.tool_calls)
196
+ for index, call in enumerate(calls):
197
+ if not call.id:
198
+ call.id = f"call_{index}"
199
+ self._tool_followup = bool(calls)
200
+ # Show the note that introduces this tool batch. The final answer
201
+ # has no tool calls and is printed once, after the turn returns.
202
+ # Deltas already painted this text. Printing it again would repeat it.
203
+ if (
204
+ calls
205
+ and resp.text.strip()
206
+ and on_step_text is not None
207
+ and on_delta is None
208
+ ):
209
+ on_step_text(resp.text)
147
210
  self.messages.append(
148
211
  ProviderMessage(
149
212
  role="assistant",
@@ -153,28 +216,46 @@ class AgentRuntime:
153
216
  )
154
217
  return resp
155
218
 
219
+ def remember_result(call: ToolCall, text: str) -> None:
220
+ self.messages.append(
221
+ ProviderMessage(role="tool", content=text, tool_call_id=call.id)
222
+ )
223
+ if on_tool_result is not None:
224
+ on_tool_result(call, text)
225
+
156
226
  dag = self.workspace / ".pari" / "sessions" / f"{self.session_id}.jsonl"
157
227
 
158
228
  def remember_warning(text: str) -> None:
159
229
  self.turn_warning = text
160
230
 
161
231
  loop = AgentExecutionLoop(
232
+ max_turns if max_turns is not None else MAX_TURNS,
162
233
  registry=self.tools,
163
234
  reflection=ReflectionManager(),
164
235
  logger=SessionLogger(dag),
165
236
  undo=self.undo_manager,
166
237
  workspace=self.workspace,
167
238
  on_tool_call=on_tool_call,
239
+ on_tool_result=remember_result,
168
240
  on_diff=on_diff,
169
241
  on_warning=remember_warning,
170
242
  )
171
- result = await loop.run_turn(
172
- prompt,
173
- step,
174
- lambda response: is_length_stop(response.stop_reason),
175
- active_files=self._pinned_contents(),
176
- repo_map=self._repo_map(),
177
- )
243
+ # A failed model call must not leave the prompt sitting on the transcript.
244
+ # The next message would otherwise resend a turn the provider never answered.
245
+ sent = len(self.messages)
246
+ try:
247
+ result = await loop.run_turn(
248
+ prompt,
249
+ step,
250
+ lambda response: is_length_stop(response.stop_reason),
251
+ active_files=self._pinned_contents(),
252
+ repo_map=self._repo_map(),
253
+ )
254
+ except ProviderError:
255
+ self._tool_followup = False
256
+ if len(self.messages) == sent + 1 and self.messages[-1].role == "user":
257
+ self.messages.pop()
258
+ raise
178
259
  turn_record = {
179
260
  "event": "turn_complete",
180
261
  "session_id": self.session_id,
@@ -189,6 +270,8 @@ class AgentRuntime:
189
270
  self.tokens_in = result.tokens_in
190
271
  self.tokens_out = result.tokens_out
191
272
  self.cached_tokens = result.cached_tokens
273
+ self.last_result = result
274
+ self.awaiting_limit = result.response_text == TURN_LIMIT_PROMPT
192
275
  return result.response_text
193
276
 
194
277
  def _repo_map(self) -> str:
@@ -35,9 +35,8 @@ class FiberSubagentPool:
35
35
  self.max_concurrency = max_concurrency
36
36
  self._slots = asyncio.Semaphore(max_concurrency)
37
37
 
38
- async def run_task(self, task_id: str, coro: Coroutine) -> Any:
38
+ async def run_task(self, _task_id: str, coro: Coroutine) -> Any:
39
39
  """Run one research task, waiting if the pool is already full."""
40
- _ = task_id
41
40
  async with self._slots:
42
41
  return await coro
43
42
 
@@ -1,13 +1,14 @@
1
- """Account sign-in for the five providers.
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
- if the local callback does not finish. Grok uses a device code. DeepSeek has
5
- no browser login, so the account token is pasted. Radius is not a provider.
4
+ if the local callback does not finish. Grok uses a device code. A provider
5
+ with ``account=False`` has no account login. Radius is not a provider.
6
6
  """
7
7
 
8
8
  import base64
9
9
  import errno
10
10
  import hashlib
11
+ import html
11
12
  import json
12
13
  import re
13
14
  import secrets
@@ -21,7 +22,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
21
22
  from typing import Protocol
22
23
 
23
24
  from pari.auth.login import sign_in
24
- from pari.auth.providers import ProviderInfo, get_info
25
+ from pari.auth.providers import ProviderInfo, get_info, no_account_message
25
26
  from pari.auth.store import AuthStore
26
27
 
27
28
 
@@ -34,6 +35,8 @@ class AccountUI(Protocol):
34
35
 
35
36
  def read_line(self, prompt: str) -> str | None: ...
36
37
 
38
+ def read_secret(self, prompt: str) -> str | None: ...
39
+
37
40
  def show_auth_url(
38
41
  self,
39
42
  url: str,
@@ -46,6 +49,8 @@ class AccountUI(Protocol):
46
49
 
47
50
  def sleep(self, seconds: float) -> bool: ...
48
51
 
52
+ def progress(self, message: str) -> None: ...
53
+
49
54
  def show_error(self, message: str) -> None: ...
50
55
 
51
56
 
@@ -67,6 +72,7 @@ _ANTHROPIC_SCOPES = (
67
72
  "org:create_api_key user:profile user:inference "
68
73
  "user:sessions:claude_code user:mcp_servers user:file_upload"
69
74
  )
75
+ _CALLBACK_PATH = "/callback"
70
76
  _ANTHROPIC_COPY_REDIRECT = "https://platform.claude.com/oauth/code/callback"
71
77
 
72
78
  _OPENROUTER_AUTHORIZE = "https://openrouter.ai/auth"
@@ -88,11 +94,8 @@ _BROWSER = (
88
94
  def sign_in_account(provider_id: str, ui: AccountUI, store: AuthStore) -> str:
89
95
  """Run that provider's account login and store the credential."""
90
96
  info = get_info(provider_id)
91
- if info.id == "deepseek":
92
- token = ui.read_line("Paste your DeepSeek account token:")
93
- if not token or not token.strip():
94
- raise LoginCancelled()
95
- return sign_in(info.id, token, "account", store)
97
+ if not info.account:
98
+ raise ValueError(no_account_message(info))
96
99
  if info.id == "openai":
97
100
  cred = _login_openai(ui, store)
98
101
  elif info.id == "anthropic":
@@ -175,9 +178,7 @@ def _login_openai(ui: AccountUI, store: AuthStore) -> dict:
175
178
  code, client_id = _code_from(server, pasted, _OPENAI_REDIRECT, state)
176
179
  if not client_id:
177
180
  raise ValueError("Paste the full callback URL from the browser.")
178
- ui_progress = getattr(ui, "progress", None)
179
- if ui_progress:
180
- ui_progress("Exchanging authorization code for tokens...")
181
+ ui.progress("Exchanging authorization code for tokens...")
181
182
  return _exchange_openai(code, verifier, client_id)
182
183
  finally:
183
184
  server.close()
@@ -195,7 +196,7 @@ def _login_anthropic(ui: AccountUI) -> dict:
195
196
  return _anthropic_copy_code(ui, verifier, challenge)
196
197
  server = _anthropic_server(verifier)
197
198
  redirect = (
198
- server.redirect_uri if server else (f"http://localhost:53692{_callback_path()}")
199
+ server.redirect_uri if server else f"http://localhost:53692{_CALLBACK_PATH}"
199
200
  )
200
201
  try:
201
202
  url = _anthropic_url(challenge, verifier, redirect)
@@ -511,20 +512,18 @@ def _anthropic_url(challenge: str, state: str, redirect: str) -> str:
511
512
 
512
513
  def _anthropic_server(state: str) -> "CallbackServer | None":
513
514
  try:
514
- return _listen("127.0.0.1", 53692, "/callback", "localhost", state, "Anthropic")
515
+ return _listen(
516
+ "127.0.0.1", 53692, _CALLBACK_PATH, "localhost", state, "Anthropic"
517
+ )
515
518
  except OSError as exc:
516
519
  if exc.errno != errno.EADDRINUSE:
517
520
  return None
518
521
  try:
519
- return _listen("127.0.0.1", 0, "/callback", "localhost", state, "Anthropic")
522
+ return _listen("127.0.0.1", 0, _CALLBACK_PATH, "localhost", state, "Anthropic")
520
523
  except OSError:
521
524
  return None
522
525
 
523
526
 
524
- def _callback_path() -> str:
525
- return "/callback"
526
-
527
-
528
527
  class CallbackServer:
529
528
  """Loopback redirect. OpenAI's port stays 1455 because the redirect is fixed."""
530
529
 
@@ -619,7 +618,8 @@ def _listen(
619
618
 
620
619
  def _send(request: BaseHTTPRequestHandler, status: int, message: str) -> None:
621
620
  body = (
622
- f"<!doctype html><meta charset=utf-8><title>PARI</title><p>{_html(message)}</p>"
621
+ "<!doctype html><meta charset=utf-8><title>PARI</title>"
622
+ f"<p>{html.escape(message)}</p>"
623
623
  ).encode()
624
624
  request.send_response(status)
625
625
  request.send_header("Content-Type", "text/html; charset=utf-8")
@@ -653,6 +653,9 @@ def _request(url: str, data: bytes, content_type: str) -> tuple[int, dict]:
653
653
  return response.status, _json_body(response.read())
654
654
  except urllib.error.HTTPError as exc:
655
655
  return exc.code, _json_body(exc.read())
656
+ except (urllib.error.URLError, TimeoutError):
657
+ host = urllib.parse.urlparse(url).netloc
658
+ raise RuntimeError(f"Could not reach {host}. Check your connection.") from None
656
659
 
657
660
 
658
661
  def _json_body(raw: bytes) -> dict:
@@ -724,7 +727,3 @@ def _http_error(action: str, status: int, body: dict) -> str:
724
727
  detail = detail.get("message")
725
728
  suffix = f": {detail}" if isinstance(detail, str) and detail else ""
726
729
  return f"{action} failed (HTTP {status}){suffix}."
727
-
728
-
729
- def _html(text: str) -> str:
730
- return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")