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.
- pari_cli-0.1.6/PKG-INFO +111 -0
- pari_cli-0.1.6/README.md +68 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/__init__.py +1 -1
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/agent/guards.py +2 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/agent/loop.py +20 -4
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/agent/runtime.py +98 -15
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/agent/subagent.py +1 -2
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/auth/account.py +23 -24
- pari_cli-0.1.6/pari/auth/credential.py +128 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/auth/interactive.py +108 -18
- pari_cli-0.1.6/pari/auth/login.py +149 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/auth/menu.py +18 -11
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/auth/providers.py +15 -0
- pari_cli-0.1.6/pari/auth/secret_field.py +50 -0
- pari_cli-0.1.6/pari/auth/store.py +62 -0
- pari_cli-0.1.6/pari/auth/verify.py +55 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/cli.py +115 -28
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/edit/exact.py +5 -1
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/eval/judge.py +2 -1
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/eval/logging.py +2 -1
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/eval/metrics.py +2 -1
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/eval/runner.py +2 -1
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/fs/__init__.py +2 -1
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/fs/atomic.py +21 -2
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/intelligence/repomap.py +1 -14
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/providers/__init__.py +20 -10
- pari_cli-0.1.6/pari/providers/anthropic.py +83 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/providers/base.py +2 -30
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/providers/deepseek.py +2 -2
- pari_cli-0.1.6/pari/providers/exceptions/__init__.py +26 -0
- pari_cli-0.1.6/pari/providers/exceptions/classifier.py +60 -0
- pari_cli-0.1.6/pari/providers/exceptions/mapping.py +90 -0
- pari_cli-0.1.6/pari/providers/exceptions/types.py +40 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/providers/grok.py +1 -0
- pari_cli-0.1.6/pari/providers/http.py +99 -0
- pari_cli-0.1.6/pari/providers/message.py +31 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/providers/mock.py +15 -13
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/providers/ollama.py +7 -2
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/providers/openai.py +1 -0
- pari_cli-0.1.6/pari/providers/openai_compat.py +79 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/providers/openrouter.py +1 -0
- pari_cli-0.1.6/pari/providers/response.py +33 -0
- pari_cli-0.1.6/pari/providers/schemas/__init__.py +42 -0
- pari_cli-0.1.6/pari/providers/schemas/anthropic.py +207 -0
- pari_cli-0.1.6/pari/providers/schemas/openai.py +259 -0
- pari_cli-0.1.6/pari/providers/schemas/types.py +17 -0
- pari_cli-0.1.6/pari/providers/stream.py +214 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/routing/router.py +10 -2
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/sandbox/base.py +77 -16
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/sandbox/bwrap.py +3 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/sandbox/e2b.py +5 -4
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/sandbox/process.py +3 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/sandbox/seatbelt.py +4 -4
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tools/bash.py +14 -3
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tools/fs.py +81 -17
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tools/registry.py +10 -2
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tools/search.py +26 -2
- pari_cli-0.1.6/pari/tui/activity.py +230 -0
- pari_cli-0.1.6/pari/tui/app.py +494 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tui/completer.py +27 -0
- pari_cli-0.1.6/pari/tui/errors.py +22 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tui/keys.py +34 -3
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tui/prompt.py +9 -1
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tui/slash.py +2 -6
- pari_cli-0.1.6/pari/tui/transcript.py +44 -0
- pari_cli-0.1.6/pari_cli.egg-info/PKG-INFO +111 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari_cli.egg-info/SOURCES.txt +18 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pyproject.toml +2 -2
- pari_cli-0.1.3/PKG-INFO +0 -175
- pari_cli-0.1.3/README.md +0 -132
- pari_cli-0.1.3/pari/auth/login.py +0 -158
- pari_cli-0.1.3/pari/auth/store.py +0 -43
- pari_cli-0.1.3/pari/providers/anthropic.py +0 -167
- pari_cli-0.1.3/pari/providers/openai_compat.py +0 -141
- pari_cli-0.1.3/pari/tui/app.py +0 -157
- pari_cli-0.1.3/pari_cli.egg-info/PKG-INFO +0 -175
- {pari_cli-0.1.3 → pari_cli-0.1.6}/LICENSE +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/__main__.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/agent/__init__.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/agent/lint.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/agent/reflection.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/auth/__init__.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/config.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/context/__init__.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/context/cache.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/context/chunks.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/context/compaction.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/context/epoch.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/context/warmer.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/edit/__init__.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/edit/cascade.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/edit/diff.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/edit/dmp.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/edit/git_osr.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/edit/indenter.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/eval/__init__.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/fs/path.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/fs/queue.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/intelligence/__init__.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/intelligence/ast.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/intelligence/cache.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/intelligence/ppr.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/intelligence/search.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/output/__init__.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/output/parser.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/output/spooler.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/output/truncation.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/routing/__init__.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/routing/anti_verbosity.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/routing/classifier.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/sandbox/__init__.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/sandbox/dispatcher.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/session/__init__.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/session/checkpoint.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/session/dag.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/session/events.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/session/logger.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/session/undo.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tools/__init__.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tools/base.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tools/output_reader.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tui/__init__.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tui/commands.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tui/diff_view.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tui/status.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari/tui/style.py +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari_cli.egg-info/dependency_links.txt +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari_cli.egg-info/entry_points.txt +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari_cli.egg-info/requires.txt +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/pari_cli.egg-info/top_level.txt +0 -0
- {pari_cli-0.1.3 → pari_cli-0.1.6}/setup.cfg +0 -0
pari_cli-0.1.6/PKG-INFO
ADDED
|
@@ -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)
|
|
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/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.
|
pari_cli-0.1.6/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/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.
|
|
@@ -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.
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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,
|
|
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
|
|
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.
|
|
5
|
-
|
|
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.
|
|
92
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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("&", "&").replace("<", "<").replace(">", ">")
|