pari-cli 0.1.1__tar.gz → 0.1.3__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.1/pari_cli.egg-info → pari_cli-0.1.3}/PKG-INFO +7 -6
- {pari_cli-0.1.1 → pari_cli-0.1.3}/README.md +2 -4
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari/__init__.py +1 -2
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari/__main__.py +2 -0
- pari_cli-0.1.3/pari/agent/__init__.py +43 -0
- pari_cli-0.1.3/pari/agent/guards.py +33 -0
- pari_cli-0.1.3/pari/agent/lint.py +49 -0
- pari_cli-0.1.3/pari/agent/loop.py +374 -0
- pari_cli-0.1.3/pari/agent/reflection.py +75 -0
- pari_cli-0.1.3/pari/agent/runtime.py +208 -0
- pari_cli-0.1.3/pari/agent/subagent.py +58 -0
- pari_cli-0.1.3/pari/auth/__init__.py +13 -0
- pari_cli-0.1.3/pari/auth/account.py +730 -0
- pari_cli-0.1.3/pari/auth/interactive.py +464 -0
- pari_cli-0.1.3/pari/auth/login.py +158 -0
- pari_cli-0.1.3/pari/auth/menu.py +173 -0
- pari_cli-0.1.3/pari/auth/providers.py +82 -0
- pari_cli-0.1.3/pari/auth/store.py +43 -0
- pari_cli-0.1.3/pari/cli.py +241 -0
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari/config.py +2 -3
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari/context/__init__.py +18 -1
- pari_cli-0.1.3/pari/context/cache.py +31 -0
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari/context/chunks.py +17 -4
- pari_cli-0.1.3/pari/context/compaction.py +58 -0
- pari_cli-0.1.3/pari/context/epoch.py +56 -0
- pari_cli-0.1.3/pari/context/warmer.py +96 -0
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari/edit/__init__.py +24 -1
- pari_cli-0.1.3/pari/edit/cascade.py +76 -0
- pari_cli-0.1.3/pari/edit/diff.py +19 -0
- pari_cli-0.1.3/pari/edit/dmp.py +46 -0
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari/edit/exact.py +26 -8
- pari_cli-0.1.3/pari/edit/git_osr.py +45 -0
- pari_cli-0.1.3/pari/edit/indenter.py +77 -0
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari/eval/__init__.py +6 -3
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari/eval/judge.py +8 -4
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari/eval/logging.py +2 -2
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari/eval/metrics.py +2 -2
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari/eval/runner.py +4 -4
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari/fs/__init__.py +11 -3
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari/fs/atomic.py +4 -3
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari/fs/path.py +7 -5
- pari_cli-0.1.3/pari/fs/queue.py +51 -0
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari/intelligence/__init__.py +18 -2
- pari_cli-0.1.3/pari/intelligence/ast.py +58 -0
- pari_cli-0.1.3/pari/intelligence/cache.py +66 -0
- pari_cli-0.1.3/pari/intelligence/ppr.py +67 -0
- pari_cli-0.1.3/pari/intelligence/repomap.py +128 -0
- pari_cli-0.1.3/pari/intelligence/search.py +90 -0
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari/output/__init__.py +13 -2
- pari_cli-0.1.3/pari/output/parser.py +18 -0
- pari_cli-0.1.3/pari/output/spooler.py +94 -0
- pari_cli-0.1.3/pari/output/truncation.py +29 -0
- pari_cli-0.1.3/pari/providers/__init__.py +66 -0
- pari_cli-0.1.3/pari/providers/anthropic.py +167 -0
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari/providers/base.py +11 -10
- pari_cli-0.1.3/pari/providers/deepseek.py +29 -0
- pari_cli-0.1.3/pari/providers/grok.py +29 -0
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari/providers/mock.py +5 -4
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari/providers/ollama.py +11 -5
- pari_cli-0.1.3/pari/providers/openai.py +29 -0
- pari_cli-0.1.3/pari/providers/openai_compat.py +141 -0
- pari_cli-0.1.3/pari/providers/openrouter.py +29 -0
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari/routing/__init__.py +18 -4
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari/routing/anti_verbosity.py +9 -2
- pari_cli-0.1.3/pari/routing/classifier.py +33 -0
- pari_cli-0.1.3/pari/routing/router.py +135 -0
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari/sandbox/__init__.py +14 -1
- pari_cli-0.1.3/pari/sandbox/base.py +386 -0
- pari_cli-0.1.3/pari/sandbox/bwrap.py +159 -0
- pari_cli-0.1.3/pari/sandbox/dispatcher.py +52 -0
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari/sandbox/e2b.py +10 -5
- pari_cli-0.1.3/pari/sandbox/process.py +54 -0
- pari_cli-0.1.3/pari/sandbox/seatbelt.py +30 -0
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari/session/__init__.py +21 -4
- pari_cli-0.1.3/pari/session/checkpoint.py +43 -0
- pari_cli-0.1.3/pari/session/dag.py +56 -0
- pari_cli-0.1.3/pari/session/events.py +92 -0
- pari_cli-0.1.3/pari/session/logger.py +149 -0
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari/session/undo.py +17 -24
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari/tools/__init__.py +4 -1
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari/tools/base.py +8 -6
- pari_cli-0.1.3/pari/tools/bash.py +74 -0
- pari_cli-0.1.3/pari/tools/fs.py +159 -0
- pari_cli-0.1.3/pari/tools/output_reader.py +75 -0
- pari_cli-0.1.3/pari/tools/registry.py +63 -0
- pari_cli-0.1.3/pari/tools/search.py +74 -0
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari/tui/__init__.py +9 -4
- pari_cli-0.1.3/pari/tui/app.py +157 -0
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari/tui/commands.py +6 -6
- pari_cli-0.1.3/pari/tui/completer.py +55 -0
- pari_cli-0.1.3/pari/tui/diff_view.py +22 -0
- pari_cli-0.1.3/pari/tui/keys.py +46 -0
- pari_cli-0.1.3/pari/tui/prompt.py +76 -0
- pari_cli-0.1.3/pari/tui/slash.py +130 -0
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari/tui/status.py +34 -5
- pari_cli-0.1.3/pari/tui/style.py +27 -0
- {pari_cli-0.1.1 → pari_cli-0.1.3/pari_cli.egg-info}/PKG-INFO +7 -6
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari_cli.egg-info/SOURCES.txt +19 -12
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari_cli.egg-info/entry_points.txt +0 -1
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari_cli.egg-info/requires.txt +4 -1
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pyproject.toml +52 -3
- pari_cli-0.1.1/pari/agent/__init__.py +0 -14
- pari_cli-0.1.1/pari/agent/loop.py +0 -35
- pari_cli-0.1.1/pari/agent/reflection.py +0 -39
- pari_cli-0.1.1/pari/agent/runtime.py +0 -91
- pari_cli-0.1.1/pari/agent/subagent.py +0 -34
- pari_cli-0.1.1/pari/cli.py +0 -132
- pari_cli-0.1.1/pari/context/cache.py +0 -15
- pari_cli-0.1.1/pari/context/compaction.py +0 -21
- pari_cli-0.1.1/pari/context/epoch.py +0 -32
- pari_cli-0.1.1/pari/context/warmer.py +0 -32
- pari_cli-0.1.1/pari/edit/cascade.py +0 -22
- pari_cli-0.1.1/pari/edit/diff.py +0 -8
- pari_cli-0.1.1/pari/edit/dmp.py +0 -15
- pari_cli-0.1.1/pari/edit/git_osr.py +0 -14
- pari_cli-0.1.1/pari/edit/indenter.py +0 -12
- pari_cli-0.1.1/pari/fs/queue.py +0 -22
- pari_cli-0.1.1/pari/intelligence/ast.py +0 -25
- pari_cli-0.1.1/pari/intelligence/cache.py +0 -21
- pari_cli-0.1.1/pari/intelligence/ppr.py +0 -21
- pari_cli-0.1.1/pari/intelligence/repomap.py +0 -21
- pari_cli-0.1.1/pari/intelligence/search.py +0 -33
- pari_cli-0.1.1/pari/output/parser.py +0 -14
- pari_cli-0.1.1/pari/output/spooler.py +0 -33
- pari_cli-0.1.1/pari/output/truncation.py +0 -20
- pari_cli-0.1.1/pari/providers/__init__.py +0 -24
- pari_cli-0.1.1/pari/providers/anthropic.py +0 -23
- pari_cli-0.1.1/pari/providers/deepseek.py +0 -24
- pari_cli-0.1.1/pari/providers/openai.py +0 -23
- pari_cli-0.1.1/pari/routing/classifier.py +0 -24
- pari_cli-0.1.1/pari/routing/router.py +0 -23
- pari_cli-0.1.1/pari/sandbox/base.py +0 -74
- pari_cli-0.1.1/pari/sandbox/bwrap.py +0 -39
- pari_cli-0.1.1/pari/sandbox/dispatcher.py +0 -31
- pari_cli-0.1.1/pari/sandbox/process.py +0 -38
- pari_cli-0.1.1/pari/sandbox/seatbelt.py +0 -32
- pari_cli-0.1.1/pari/session/checkpoint.py +0 -24
- pari_cli-0.1.1/pari/session/dag.py +0 -34
- pari_cli-0.1.1/pari/session/events.py +0 -50
- pari_cli-0.1.1/pari/session/logger.py +0 -41
- pari_cli-0.1.1/pari/tools/bash.py +0 -24
- pari_cli-0.1.1/pari/tools/fs.py +0 -53
- pari_cli-0.1.1/pari/tools/registry.py +0 -36
- pari_cli-0.1.1/pari/tools/search.py +0 -38
- pari_cli-0.1.1/pari/tui/app.py +0 -356
- pari_cli-0.1.1/pari/tui/diff_view.py +0 -17
- pari_cli-0.1.1/tests/test_agent_runtime.py +0 -55
- pari_cli-0.1.1/tests/test_context.py +0 -38
- pari_cli-0.1.1/tests/test_edit.py +0 -53
- pari_cli-0.1.1/tests/test_fs.py +0 -48
- pari_cli-0.1.1/tests/test_output.py +0 -38
- pari_cli-0.1.1/tests/test_routing.py +0 -21
- pari_cli-0.1.1/tests/test_sandbox.py +0 -20
- pari_cli-0.1.1/tests/test_search.py +0 -26
- pari_cli-0.1.1/tests/test_session.py +0 -41
- pari_cli-0.1.1/tests/test_tools.py +0 -41
- pari_cli-0.1.1/tests/test_tui.py +0 -103
- {pari_cli-0.1.1 → pari_cli-0.1.3}/LICENSE +0 -0
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari_cli.egg-info/dependency_links.txt +0 -0
- {pari_cli-0.1.1 → pari_cli-0.1.3}/pari_cli.egg-info/top_level.txt +0 -0
- {pari_cli-0.1.1 → pari_cli-0.1.3}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pari-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: A resilient, sandbox-first terminal coding agent engineered for high pass rates and low token burn.
|
|
5
5
|
Author: PARI Contributors
|
|
6
6
|
License: MIT
|
|
@@ -27,6 +27,9 @@ Requires-Dist: httpx>=0.24.0
|
|
|
27
27
|
Provides-Extra: dev
|
|
28
28
|
Requires-Dist: pytest>=7.4.0; extra == "dev"
|
|
29
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"
|
|
30
33
|
Provides-Extra: ast
|
|
31
34
|
Requires-Dist: tree-sitter>=0.21.0; extra == "ast"
|
|
32
35
|
Requires-Dist: tree-sitter-languages>=1.10.0; extra == "ast"
|
|
@@ -35,7 +38,7 @@ Requires-Dist: grep-ast>=0.3.0; extra == "ast"
|
|
|
35
38
|
Provides-Extra: cloud
|
|
36
39
|
Requires-Dist: e2b>=0.14.0; extra == "cloud"
|
|
37
40
|
Provides-Extra: all
|
|
38
|
-
Requires-Dist: pari[ast,cloud,dev]; extra == "all"
|
|
41
|
+
Requires-Dist: pari-cli[ast,cloud,dev]; extra == "all"
|
|
39
42
|
Dynamic: license-file
|
|
40
43
|
|
|
41
44
|
# PARI — Next-Generation Terminal Coding Agent
|
|
@@ -130,19 +133,17 @@ All system documentation has been modularized and organized inside the [`docs/`]
|
|
|
130
133
|
|
|
131
134
|
Official Website & Documentation: **[https://pari-cli.in](https://pari-cli.in)**
|
|
132
135
|
|
|
133
|
-
Choose your platform's
|
|
136
|
+
Choose your platform's installation command:
|
|
134
137
|
|
|
135
138
|
### 1. macOS & Linux (`curl`)
|
|
136
139
|
```bash
|
|
137
140
|
curl -fsSL https://pari-cli.in/install.sh | bash
|
|
138
141
|
```
|
|
139
|
-
*(Alternative via GitHub: `curl -fsSL https://raw.githubusercontent.com/MdTanwer/pari/main/install.sh | bash`)*
|
|
140
142
|
|
|
141
143
|
### 2. Windows (`irm` in PowerShell)
|
|
142
144
|
```powershell
|
|
143
145
|
irm https://pari-cli.in/install.ps1 | iex
|
|
144
146
|
```
|
|
145
|
-
*(Alternative via GitHub: `irm https://raw.githubusercontent.com/MdTanwer/pari/main/install.ps1 | iex`)*
|
|
146
147
|
|
|
147
148
|
### 3. Via `pip` / `pipx` / `uv`
|
|
148
149
|
```bash
|
|
@@ -164,7 +165,7 @@ pip install pari-cli
|
|
|
164
165
|
export ANTHROPIC_API_KEY="sk-ant-..."
|
|
165
166
|
|
|
166
167
|
# Launch PARI anywhere in your project workspace
|
|
167
|
-
pari
|
|
168
|
+
pari-cli
|
|
168
169
|
```
|
|
169
170
|
|
|
170
171
|
---
|
|
@@ -90,19 +90,17 @@ All system documentation has been modularized and organized inside the [`docs/`]
|
|
|
90
90
|
|
|
91
91
|
Official Website & Documentation: **[https://pari-cli.in](https://pari-cli.in)**
|
|
92
92
|
|
|
93
|
-
Choose your platform's
|
|
93
|
+
Choose your platform's installation command:
|
|
94
94
|
|
|
95
95
|
### 1. macOS & Linux (`curl`)
|
|
96
96
|
```bash
|
|
97
97
|
curl -fsSL https://pari-cli.in/install.sh | bash
|
|
98
98
|
```
|
|
99
|
-
*(Alternative via GitHub: `curl -fsSL https://raw.githubusercontent.com/MdTanwer/pari/main/install.sh | bash`)*
|
|
100
99
|
|
|
101
100
|
### 2. Windows (`irm` in PowerShell)
|
|
102
101
|
```powershell
|
|
103
102
|
irm https://pari-cli.in/install.ps1 | iex
|
|
104
103
|
```
|
|
105
|
-
*(Alternative via GitHub: `irm https://raw.githubusercontent.com/MdTanwer/pari/main/install.ps1 | iex`)*
|
|
106
104
|
|
|
107
105
|
### 3. Via `pip` / `pipx` / `uv`
|
|
108
106
|
```bash
|
|
@@ -124,7 +122,7 @@ pip install pari-cli
|
|
|
124
122
|
export ANTHROPIC_API_KEY="sk-ant-..."
|
|
125
123
|
|
|
126
124
|
# Launch PARI anywhere in your project workspace
|
|
127
|
-
pari
|
|
125
|
+
pari-cli
|
|
128
126
|
```
|
|
129
127
|
|
|
130
128
|
---
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""PARI Layer 4: The Execution Loop (Agent Runtime).
|
|
2
|
+
|
|
3
|
+
Architectural Purpose:
|
|
4
|
+
Implements the double-while execution loop with stop_reason == "length" truncation
|
|
5
|
+
guard,
|
|
6
|
+
bounded reflection retry limits, and lightweight asyncio fiber-based subagents.
|
|
7
|
+
|
|
8
|
+
Subsystems:
|
|
9
|
+
- loop: Core double-while agent loop with continuation handling
|
|
10
|
+
- runtime: Turn execution, tool orchestration, and state transitions
|
|
11
|
+
- reflection: Structured error injection with max 3 reflection retries
|
|
12
|
+
- subagent: Fiber-based read-only research subagents (closing Pi F2 RAM bloat)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from pari.agent.guards import (
|
|
16
|
+
DAEMON_IDLE_SECONDS,
|
|
17
|
+
LENGTH_TRUNCATION_NOTICE,
|
|
18
|
+
MAX_TURNS,
|
|
19
|
+
TURN_WARNING,
|
|
20
|
+
format_daemon_notice,
|
|
21
|
+
is_length_stop,
|
|
22
|
+
)
|
|
23
|
+
from pari.agent.loop import AgentExecutionLoop, TurnResult
|
|
24
|
+
from pari.agent.reflection import ReflectionManager, format_lint_error
|
|
25
|
+
from pari.agent.runtime import AgentRuntime
|
|
26
|
+
from pari.agent.subagent import FiberSubagentPool, SubagentTask
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"DAEMON_IDLE_SECONDS",
|
|
31
|
+
"LENGTH_TRUNCATION_NOTICE",
|
|
32
|
+
"MAX_TURNS",
|
|
33
|
+
"TURN_WARNING",
|
|
34
|
+
"AgentExecutionLoop",
|
|
35
|
+
"AgentRuntime",
|
|
36
|
+
"FiberSubagentPool",
|
|
37
|
+
"ReflectionManager",
|
|
38
|
+
"SubagentTask",
|
|
39
|
+
"TurnResult",
|
|
40
|
+
"format_daemon_notice",
|
|
41
|
+
"format_lint_error",
|
|
42
|
+
"is_length_stop",
|
|
43
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Constants and checks the execution loop applies before it runs a tool.
|
|
2
|
+
|
|
3
|
+
The loop itself decides when to stop. These values are the limits it checks.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
MAX_TURNS = 40
|
|
7
|
+
TURN_WARNING = 30
|
|
8
|
+
DAEMON_IDLE_SECONDS = 10
|
|
9
|
+
|
|
10
|
+
# Shown when stop_reason is "length". The loop must drop the pending tool
|
|
11
|
+
# calls before it appends this notice. A truncated edit must not touch disk.
|
|
12
|
+
LENGTH_TRUNCATION_NOTICE = (
|
|
13
|
+
"WARNING: Your previous tool call was truncated due to the "
|
|
14
|
+
"output token ceiling. No changes were written to disk. "
|
|
15
|
+
"Please break your modification into smaller, focused "
|
|
16
|
+
"search/replace blocks of <= 100 lines."
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_length_stop(stop_reason: str | None) -> bool:
|
|
21
|
+
"""True when the model hit max_tokens and the tool payload may be cut off."""
|
|
22
|
+
return stop_reason == "length"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def format_daemon_notice(command: str, idle_seconds: int = DAEMON_IDLE_SECONDS) -> str:
|
|
26
|
+
"""Notice for a command that kept streaming past the idle window."""
|
|
27
|
+
shown = command.strip() or "command"
|
|
28
|
+
return (
|
|
29
|
+
f"[DAEMON DETECTED]: Command '{shown}' did not exit after "
|
|
30
|
+
f"{idle_seconds}s. Process was backgrounded. "
|
|
31
|
+
"Stdout snapshot spooled to disk. "
|
|
32
|
+
"To manage background daemons, use background task APIs."
|
|
33
|
+
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Syntax check for a file the loop just wrote.
|
|
2
|
+
|
|
3
|
+
The check uses the standard library parser. It returns a short window around
|
|
4
|
+
the failing line so the next model turn does not receive a compiler log.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import ast
|
|
8
|
+
|
|
9
|
+
from pari.agent.reflection import format_lint_error
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def lint_python(path: str, source: str) -> str | None:
|
|
13
|
+
"""Return a lint block when path is Python and source does not parse."""
|
|
14
|
+
if not path.endswith(".py"):
|
|
15
|
+
return None
|
|
16
|
+
try:
|
|
17
|
+
ast.parse(source)
|
|
18
|
+
except SyntaxError as exc:
|
|
19
|
+
line = exc.lineno or 1
|
|
20
|
+
return format_lint_error(
|
|
21
|
+
path,
|
|
22
|
+
line,
|
|
23
|
+
_enclosing(source, line),
|
|
24
|
+
_window(source, line, exc.msg),
|
|
25
|
+
)
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _enclosing(source: str, line: int) -> str:
|
|
30
|
+
rows = source.splitlines()
|
|
31
|
+
last = min(line, len(rows)) - 1
|
|
32
|
+
for index in range(last, -1, -1):
|
|
33
|
+
stripped = rows[index].lstrip()
|
|
34
|
+
if stripped.startswith(("def ", "async def ", "class ")):
|
|
35
|
+
return stripped
|
|
36
|
+
return "<module>"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _window(source: str, line: int, message: str) -> str:
|
|
40
|
+
rows = source.splitlines()
|
|
41
|
+
start = max(line - 5, 1)
|
|
42
|
+
end = min(line + 5, len(rows))
|
|
43
|
+
shown: list[str] = []
|
|
44
|
+
for number in range(start, end + 1):
|
|
45
|
+
text = rows[number - 1] if number <= len(rows) else ""
|
|
46
|
+
if number == line:
|
|
47
|
+
text = f"{text} # {message}"
|
|
48
|
+
shown.append(f"{number}: {text}")
|
|
49
|
+
return "\n".join(shown)
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""PARI Layer 4: Double-While Execution Loop.
|
|
2
|
+
|
|
3
|
+
Outer loop: one model call per autonomous turn, up to the turn ceiling.
|
|
4
|
+
Inner step: one provider completion. A length stop drops that completion's
|
|
5
|
+
tool calls. Tool results, lint failures, and daemon notices become the next
|
|
6
|
+
prompt. The turn ends when the model stops asking for tools.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import subprocess
|
|
11
|
+
from collections.abc import Awaitable, Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Protocol
|
|
15
|
+
|
|
16
|
+
from pari.agent.guards import (
|
|
17
|
+
DAEMON_IDLE_SECONDS,
|
|
18
|
+
LENGTH_TRUNCATION_NOTICE,
|
|
19
|
+
MAX_TURNS,
|
|
20
|
+
TURN_WARNING,
|
|
21
|
+
format_daemon_notice,
|
|
22
|
+
is_length_stop,
|
|
23
|
+
)
|
|
24
|
+
from pari.agent.lint import lint_python
|
|
25
|
+
from pari.agent.reflection import ReflectionManager
|
|
26
|
+
from pari.context.chunks import ContextChunk, ContextTier, assemble_context
|
|
27
|
+
from pari.context.compaction import compact_context
|
|
28
|
+
from pari.edit.diff import generate_unified_diff
|
|
29
|
+
from pari.fs.path import resolve_safe_path
|
|
30
|
+
from pari.providers.base import ModelResponse, ToolCall
|
|
31
|
+
from pari.session.logger import SessionLogger
|
|
32
|
+
from pari.session.undo import GuardedUndoManager
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ToolExecutor(Protocol):
|
|
36
|
+
async def execute(self, name: str, arguments: dict[str, Any]) -> Any: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class TurnResult:
|
|
41
|
+
response_text: str
|
|
42
|
+
tool_calls_executed: int
|
|
43
|
+
tokens_used: int
|
|
44
|
+
requires_user_input: bool
|
|
45
|
+
warning: str = ""
|
|
46
|
+
diff: str = ""
|
|
47
|
+
tokens_in: int = 0
|
|
48
|
+
tokens_out: int = 0
|
|
49
|
+
cached_tokens: int = 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_TURN_LIMIT = "[Turn limit reached. Continue (+10 turns) | Revert | Stop]?"
|
|
53
|
+
_FILE_TOOLS = frozenset({"write_file", "edit_file"})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AgentExecutionLoop:
|
|
57
|
+
"""Runs autonomous tool turns until the model stops or a guard fires."""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
max_autonomous_turns: int = MAX_TURNS,
|
|
62
|
+
*,
|
|
63
|
+
registry: ToolExecutor | None = None,
|
|
64
|
+
reflection: ReflectionManager | None = None,
|
|
65
|
+
logger: SessionLogger | None = None,
|
|
66
|
+
undo: GuardedUndoManager | None = None,
|
|
67
|
+
workspace: Path | None = None,
|
|
68
|
+
on_tool_call: Callable[[str, dict[str, Any]], None] | None = None,
|
|
69
|
+
on_diff: Callable[[str, str], None] | None = None,
|
|
70
|
+
on_warning: Callable[[str], None] | None = None,
|
|
71
|
+
):
|
|
72
|
+
self.max_autonomous_turns = max_autonomous_turns
|
|
73
|
+
self.registry = registry
|
|
74
|
+
self.reflection = reflection or ReflectionManager()
|
|
75
|
+
self.logger = logger
|
|
76
|
+
self.undo = undo
|
|
77
|
+
self.workspace = workspace
|
|
78
|
+
self.on_tool_call = on_tool_call
|
|
79
|
+
self.on_diff = on_diff
|
|
80
|
+
self.on_warning = on_warning
|
|
81
|
+
self._warning = ""
|
|
82
|
+
|
|
83
|
+
async def run_turn(
|
|
84
|
+
self,
|
|
85
|
+
prompt: str,
|
|
86
|
+
step_fn: Callable[[str, int], Any],
|
|
87
|
+
is_length_truncated: Callable[[Any], bool],
|
|
88
|
+
active_files: list[tuple[str, str]] | None = None,
|
|
89
|
+
repo_map: str = "",
|
|
90
|
+
) -> TurnResult:
|
|
91
|
+
"""Execute autonomous steps for one user prompt.
|
|
92
|
+
|
|
93
|
+
step_fn(context, turn) returns a ModelResponse or a plain string.
|
|
94
|
+
A length stop is not executed: the tool payload may be cut off.
|
|
95
|
+
"""
|
|
96
|
+
self.reflection.reset("turn")
|
|
97
|
+
if self.logger is not None:
|
|
98
|
+
self.logger.append_event("USER_TURN", {"prompt": prompt})
|
|
99
|
+
pending = prompt
|
|
100
|
+
history = ""
|
|
101
|
+
executed = 0
|
|
102
|
+
tokens_in = 0
|
|
103
|
+
tokens_out = 0
|
|
104
|
+
cached_tokens = 0
|
|
105
|
+
response_text = ""
|
|
106
|
+
|
|
107
|
+
def finish(
|
|
108
|
+
text: str, count: int, needs_user: bool, *, capture_diff: bool = False
|
|
109
|
+
) -> TurnResult:
|
|
110
|
+
return self._finish(
|
|
111
|
+
text,
|
|
112
|
+
count,
|
|
113
|
+
tokens_in + tokens_out,
|
|
114
|
+
needs_user,
|
|
115
|
+
capture_diff=capture_diff,
|
|
116
|
+
tokens_in=tokens_in,
|
|
117
|
+
tokens_out=tokens_out,
|
|
118
|
+
cached_tokens=cached_tokens,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
for turn in range(1, self.max_autonomous_turns + 1):
|
|
122
|
+
self._note_turn(turn)
|
|
123
|
+
context = _render_context(pending, history, active_files or [], repo_map)
|
|
124
|
+
step = await _resolve(step_fn(context, turn))
|
|
125
|
+
step_in, step_out, step_cached = _usage(step)
|
|
126
|
+
tokens_in += step_in
|
|
127
|
+
tokens_out += step_out
|
|
128
|
+
cached_tokens += step_cached
|
|
129
|
+
response_text = _text(step)
|
|
130
|
+
# A truncated tool call is discarded. The file is left untouched.
|
|
131
|
+
if _truncated(step, is_length_truncated):
|
|
132
|
+
history = _append(history, response_text)
|
|
133
|
+
pending = LENGTH_TRUNCATION_NOTICE
|
|
134
|
+
continue
|
|
135
|
+
calls = _calls(step)
|
|
136
|
+
if not calls:
|
|
137
|
+
return finish(response_text, executed, False)
|
|
138
|
+
feedback, executed, halt = await self._run_calls(calls, executed)
|
|
139
|
+
if halt is not None:
|
|
140
|
+
return finish(halt, executed, True)
|
|
141
|
+
history = _append(history, response_text)
|
|
142
|
+
pending = feedback
|
|
143
|
+
return finish(_TURN_LIMIT, executed, True, capture_diff=True)
|
|
144
|
+
|
|
145
|
+
async def _run_calls(
|
|
146
|
+
self, calls: list[ToolCall], executed: int
|
|
147
|
+
) -> tuple[str, int, str | None]:
|
|
148
|
+
"""Run the calls in order. A failure stops the rest of this batch."""
|
|
149
|
+
notes: list[str] = []
|
|
150
|
+
for call in calls:
|
|
151
|
+
path = _file_path(call)
|
|
152
|
+
before = self._read_rel(path) if path is not None else ""
|
|
153
|
+
try:
|
|
154
|
+
text = await self._execute_call(call)
|
|
155
|
+
except Exception as exc:
|
|
156
|
+
return self._fail(type(exc).__name__, str(exc), executed)
|
|
157
|
+
executed += 1
|
|
158
|
+
if path is not None:
|
|
159
|
+
after = self._read_rel(path)
|
|
160
|
+
self._snapshot(path, before, after)
|
|
161
|
+
lint = lint_python(path, after)
|
|
162
|
+
if lint is not None:
|
|
163
|
+
return self._fail("LINT ERROR", lint, executed)
|
|
164
|
+
notes.append(text)
|
|
165
|
+
return "\n".join(notes), executed, None
|
|
166
|
+
|
|
167
|
+
async def _execute_call(self, call: ToolCall) -> str:
|
|
168
|
+
if self.registry is None:
|
|
169
|
+
raise RuntimeError(f"No tool registry configured for {call.name}")
|
|
170
|
+
if self.on_tool_call is not None:
|
|
171
|
+
self.on_tool_call(call.name, call.arguments)
|
|
172
|
+
if self.logger is not None:
|
|
173
|
+
self.logger.append_event(
|
|
174
|
+
"TOOL_CALL", {"tool": call.name, "arguments": call.arguments}
|
|
175
|
+
)
|
|
176
|
+
result = await self.registry.execute(call.name, call.arguments)
|
|
177
|
+
text = _render_result(call, result)
|
|
178
|
+
if self.logger is not None:
|
|
179
|
+
self.logger.append_event("TOOL_RESULT", {"tool": call.name, "output": text})
|
|
180
|
+
return text
|
|
181
|
+
|
|
182
|
+
def _log_assistant(self, text: str) -> None:
|
|
183
|
+
if self.logger is None:
|
|
184
|
+
return
|
|
185
|
+
self.logger.append_event("ASSISTANT_TURN", {"text": text})
|
|
186
|
+
|
|
187
|
+
def _note_turn(self, turn: int) -> None:
|
|
188
|
+
"""Warn the user at turn 30. The warning stays out of the model prompt."""
|
|
189
|
+
if turn != TURN_WARNING:
|
|
190
|
+
return
|
|
191
|
+
self._warning = f"Turn {turn} of {self.max_autonomous_turns}."
|
|
192
|
+
if self.on_warning is not None:
|
|
193
|
+
self.on_warning(self._warning)
|
|
194
|
+
|
|
195
|
+
def _fail(
|
|
196
|
+
self, kind: str, message: str, executed: int
|
|
197
|
+
) -> tuple[str, int, str | None]:
|
|
198
|
+
note = self.reflection.record_failure("turn", kind, message)
|
|
199
|
+
if kind == "LINT ERROR":
|
|
200
|
+
shown = message
|
|
201
|
+
else:
|
|
202
|
+
shown = note
|
|
203
|
+
if self.reflection.should_retry("turn"):
|
|
204
|
+
return shown, executed, None
|
|
205
|
+
if kind == "LINT ERROR":
|
|
206
|
+
shown = f"{message}\nReflection limit reached. Stop and ask the user."
|
|
207
|
+
return shown, executed, shown
|
|
208
|
+
|
|
209
|
+
def _finish(
|
|
210
|
+
self,
|
|
211
|
+
text: str,
|
|
212
|
+
executed: int,
|
|
213
|
+
tokens: int,
|
|
214
|
+
needs_user: bool,
|
|
215
|
+
*,
|
|
216
|
+
capture_diff: bool = False,
|
|
217
|
+
tokens_in: int = 0,
|
|
218
|
+
tokens_out: int = 0,
|
|
219
|
+
cached_tokens: int = 0,
|
|
220
|
+
) -> TurnResult:
|
|
221
|
+
diff = ""
|
|
222
|
+
if capture_diff:
|
|
223
|
+
diff = workspace_diff(self.workspace)
|
|
224
|
+
if diff and self.on_diff is not None:
|
|
225
|
+
self.on_diff(".", diff)
|
|
226
|
+
self._log_assistant(text)
|
|
227
|
+
return TurnResult(
|
|
228
|
+
text,
|
|
229
|
+
executed,
|
|
230
|
+
tokens,
|
|
231
|
+
needs_user,
|
|
232
|
+
self._warning,
|
|
233
|
+
diff,
|
|
234
|
+
tokens_in,
|
|
235
|
+
tokens_out,
|
|
236
|
+
cached_tokens,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def _read_rel(self, rel: str) -> str:
|
|
240
|
+
if self.workspace is None:
|
|
241
|
+
return ""
|
|
242
|
+
try:
|
|
243
|
+
path = resolve_safe_path(self.workspace, rel)
|
|
244
|
+
except PermissionError:
|
|
245
|
+
return ""
|
|
246
|
+
if not path.is_file():
|
|
247
|
+
return ""
|
|
248
|
+
try:
|
|
249
|
+
return path.read_text(encoding="utf-8")
|
|
250
|
+
except (OSError, UnicodeDecodeError):
|
|
251
|
+
return ""
|
|
252
|
+
|
|
253
|
+
def _snapshot(self, rel: str, before: str, after: str) -> None:
|
|
254
|
+
if before == after:
|
|
255
|
+
return
|
|
256
|
+
if self.undo is not None:
|
|
257
|
+
self.undo.record_snapshot(rel, before, after)
|
|
258
|
+
if self.logger is not None:
|
|
259
|
+
self.logger.append_event("FILE_SNAPSHOT", {"path": rel})
|
|
260
|
+
if self.on_diff is not None:
|
|
261
|
+
self.on_diff(rel, generate_unified_diff(before, after, rel))
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _render_context(
|
|
265
|
+
prompt: str,
|
|
266
|
+
history: str,
|
|
267
|
+
active_files: list[tuple[str, str]],
|
|
268
|
+
repo_map: str = "",
|
|
269
|
+
) -> str:
|
|
270
|
+
chunks: list[ContextChunk] = []
|
|
271
|
+
if repo_map:
|
|
272
|
+
chunks.append(ContextChunk(ContextTier.STATIC_REPO_MAP, repo_map, "repomap"))
|
|
273
|
+
if active_files:
|
|
274
|
+
body = "\n".join(f"# {name}\n{content}" for name, content in active_files)
|
|
275
|
+
chunks.append(ContextChunk(ContextTier.ACTIVE_FILES, body, "files"))
|
|
276
|
+
if history:
|
|
277
|
+
chunks.append(ContextChunk(ContextTier.TURN_HISTORY, history, "history"))
|
|
278
|
+
chunks.append(ContextChunk(ContextTier.CURRENT_TURN, prompt, "turn"))
|
|
279
|
+
packed = assemble_context(compact_context(chunks))
|
|
280
|
+
return "\n".join(str(item["content"]) for item in packed)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _append(history: str, text: str) -> str:
|
|
284
|
+
if not history:
|
|
285
|
+
return text
|
|
286
|
+
return f"{history}\n{text}"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
async def _resolve(step: Any) -> Any:
|
|
290
|
+
if isinstance(step, Awaitable):
|
|
291
|
+
return await step
|
|
292
|
+
return step
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _truncated(step: Any, is_length_truncated: Callable[[Any], bool]) -> bool:
|
|
296
|
+
if is_length_truncated(step):
|
|
297
|
+
return True
|
|
298
|
+
if isinstance(step, ModelResponse):
|
|
299
|
+
return is_length_stop(step.stop_reason)
|
|
300
|
+
return False
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _calls(step: Any) -> list[ToolCall]:
|
|
304
|
+
if isinstance(step, ModelResponse):
|
|
305
|
+
return list(step.tool_calls)
|
|
306
|
+
return []
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _text(step: Any) -> str:
|
|
310
|
+
if isinstance(step, ModelResponse):
|
|
311
|
+
return step.text
|
|
312
|
+
if isinstance(step, str):
|
|
313
|
+
return step
|
|
314
|
+
raise TypeError("step_fn must return a string or ModelResponse")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _usage(step: Any) -> tuple[int, int, int]:
|
|
318
|
+
if isinstance(step, ModelResponse):
|
|
319
|
+
return step.tokens_in, step.tokens_out, step.cached_tokens
|
|
320
|
+
return 0, 0, 0
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _render_result(call: ToolCall, result: Any) -> str:
|
|
324
|
+
timed_out = False
|
|
325
|
+
if isinstance(result, str):
|
|
326
|
+
text = result
|
|
327
|
+
elif isinstance(result, dict):
|
|
328
|
+
timed_out = bool(result.get("timed_out"))
|
|
329
|
+
text = json.dumps(result)
|
|
330
|
+
else:
|
|
331
|
+
text = str(result)
|
|
332
|
+
if not timed_out:
|
|
333
|
+
return text
|
|
334
|
+
command = str(call.arguments.get("command", call.name))
|
|
335
|
+
seconds = _idle_seconds(result)
|
|
336
|
+
return format_daemon_notice(command, seconds) + "\n" + text
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _file_path(call: ToolCall) -> str | None:
|
|
340
|
+
if call.name not in _FILE_TOOLS:
|
|
341
|
+
return None
|
|
342
|
+
path = call.arguments.get("path")
|
|
343
|
+
if not isinstance(path, str) or not path:
|
|
344
|
+
return None
|
|
345
|
+
return path
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _idle_seconds(result: Any) -> int:
|
|
349
|
+
"""Prefer the daemon window over the tool's longer hard timeout."""
|
|
350
|
+
if not isinstance(result, dict):
|
|
351
|
+
return DAEMON_IDLE_SECONDS
|
|
352
|
+
for key in ("idle_seconds", "timeout"):
|
|
353
|
+
raw = result.get(key)
|
|
354
|
+
if isinstance(raw, int | float):
|
|
355
|
+
return int(raw)
|
|
356
|
+
return DAEMON_IDLE_SECONDS
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def workspace_diff(workspace: Path | None) -> str:
|
|
360
|
+
"""Return the working-tree diff, or an empty string when git cannot run."""
|
|
361
|
+
if workspace is None:
|
|
362
|
+
return ""
|
|
363
|
+
try:
|
|
364
|
+
completed = subprocess.run(
|
|
365
|
+
["git", "diff", "--no-ext-diff", "--"],
|
|
366
|
+
cwd=workspace,
|
|
367
|
+
capture_output=True,
|
|
368
|
+
text=True,
|
|
369
|
+
timeout=5,
|
|
370
|
+
check=False,
|
|
371
|
+
)
|
|
372
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
373
|
+
return ""
|
|
374
|
+
return completed.stdout
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""PARI Layer 4: Reflection & Error Injection Manager.
|
|
2
|
+
|
|
3
|
+
Architectural Purpose:
|
|
4
|
+
Closes Aider F5 (Reflection loop token burn: $0.50-$2.00 per stubborn diff).
|
|
5
|
+
When a tool call or edit fails:
|
|
6
|
+
1. Formats the error into a high-signal structured diagnostic.
|
|
7
|
+
2. Limits reflection attempts to max_reflections (default: 3).
|
|
8
|
+
3. If 3 attempts fail, halts execution and prompts the user for intervention,
|
|
9
|
+
preventing infinite token-burning retry loops.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ReflectionAttempt:
|
|
17
|
+
error_type: str
|
|
18
|
+
error_message: str
|
|
19
|
+
attempt_count: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ReflectionManager:
|
|
23
|
+
"""Guards reflection retries to prevent unbounded token expenditure."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, max_reflections: int = 3):
|
|
26
|
+
self.max_reflections = max_reflections
|
|
27
|
+
self._counts: dict[str, int] = {}
|
|
28
|
+
|
|
29
|
+
def should_retry(self, operation_id: str) -> bool:
|
|
30
|
+
"""True while this operation still has attempts left.
|
|
31
|
+
|
|
32
|
+
The count goes up in record_failure. After max_reflections failures
|
|
33
|
+
the loop must stop and ask the user instead of calling the model again.
|
|
34
|
+
"""
|
|
35
|
+
return self._counts.get(operation_id, 0) < self.max_reflections
|
|
36
|
+
|
|
37
|
+
def record_failure(self, operation_id: str, error_type: str, message: str) -> str:
|
|
38
|
+
"""Count the failure and return the prompt block for the next turn."""
|
|
39
|
+
count = self._counts.get(operation_id, 0) + 1
|
|
40
|
+
self._counts[operation_id] = count
|
|
41
|
+
attempt = ReflectionAttempt(error_type, message, count)
|
|
42
|
+
if count >= self.max_reflections:
|
|
43
|
+
ending = " Reflection limit reached. Stop and ask the user."
|
|
44
|
+
else:
|
|
45
|
+
ending = " Retry with a smaller, targeted change."
|
|
46
|
+
return (
|
|
47
|
+
f"[{attempt.error_type}] attempt {attempt.attempt_count}"
|
|
48
|
+
f"/{self.max_reflections}: {attempt.error_message}.{ending}"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def reset(self, operation_id: str | None = None) -> None:
|
|
52
|
+
"""Clear failure counts for one operation, or for every operation."""
|
|
53
|
+
if operation_id is None:
|
|
54
|
+
self._counts.clear()
|
|
55
|
+
return
|
|
56
|
+
self._counts.pop(operation_id, None)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def format_lint_error(
|
|
60
|
+
path: str,
|
|
61
|
+
line_number: int,
|
|
62
|
+
enclosing: str,
|
|
63
|
+
window: str,
|
|
64
|
+
) -> str:
|
|
65
|
+
"""Build the short lint block the loop injects after a failed edit.
|
|
66
|
+
|
|
67
|
+
The window should already be the few lines around the error. This function
|
|
68
|
+
does not read the file, so it cannot pull a whole compiler log back in.
|
|
69
|
+
"""
|
|
70
|
+
return (
|
|
71
|
+
f"[LINT ERROR] In {path}:{line_number}:\n"
|
|
72
|
+
f"Enclosing function: {enclosing}\n"
|
|
73
|
+
f"{window}\n"
|
|
74
|
+
"Fix the specific error above using a targeted search/replace edit."
|
|
75
|
+
)
|