pari-cli 0.1.2__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.
Files changed (161) hide show
  1. {pari_cli-0.1.2/pari_cli.egg-info → pari_cli-0.1.3}/PKG-INFO +6 -3
  2. {pari_cli-0.1.2 → pari_cli-0.1.3}/README.md +1 -1
  3. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari/__init__.py +1 -3
  4. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari/__main__.py +2 -0
  5. pari_cli-0.1.3/pari/agent/__init__.py +43 -0
  6. pari_cli-0.1.3/pari/agent/guards.py +33 -0
  7. pari_cli-0.1.3/pari/agent/lint.py +49 -0
  8. pari_cli-0.1.3/pari/agent/loop.py +374 -0
  9. pari_cli-0.1.3/pari/agent/reflection.py +75 -0
  10. pari_cli-0.1.3/pari/agent/runtime.py +208 -0
  11. pari_cli-0.1.3/pari/agent/subagent.py +58 -0
  12. pari_cli-0.1.3/pari/auth/__init__.py +13 -0
  13. pari_cli-0.1.3/pari/auth/account.py +730 -0
  14. pari_cli-0.1.3/pari/auth/interactive.py +464 -0
  15. pari_cli-0.1.3/pari/auth/login.py +158 -0
  16. pari_cli-0.1.3/pari/auth/menu.py +173 -0
  17. pari_cli-0.1.3/pari/auth/providers.py +82 -0
  18. pari_cli-0.1.3/pari/auth/store.py +43 -0
  19. pari_cli-0.1.3/pari/cli.py +241 -0
  20. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari/config.py +2 -3
  21. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari/context/__init__.py +18 -1
  22. pari_cli-0.1.3/pari/context/cache.py +31 -0
  23. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari/context/chunks.py +17 -4
  24. pari_cli-0.1.3/pari/context/compaction.py +58 -0
  25. pari_cli-0.1.3/pari/context/epoch.py +56 -0
  26. pari_cli-0.1.3/pari/context/warmer.py +96 -0
  27. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari/edit/__init__.py +24 -1
  28. pari_cli-0.1.3/pari/edit/cascade.py +76 -0
  29. pari_cli-0.1.3/pari/edit/diff.py +19 -0
  30. pari_cli-0.1.3/pari/edit/dmp.py +46 -0
  31. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari/edit/exact.py +26 -8
  32. pari_cli-0.1.3/pari/edit/git_osr.py +45 -0
  33. pari_cli-0.1.3/pari/edit/indenter.py +77 -0
  34. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari/eval/__init__.py +6 -3
  35. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari/eval/judge.py +8 -4
  36. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari/eval/logging.py +2 -2
  37. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari/eval/metrics.py +2 -2
  38. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari/eval/runner.py +4 -4
  39. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari/fs/__init__.py +11 -3
  40. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari/fs/atomic.py +4 -3
  41. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari/fs/path.py +7 -5
  42. pari_cli-0.1.3/pari/fs/queue.py +51 -0
  43. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari/intelligence/__init__.py +18 -2
  44. pari_cli-0.1.3/pari/intelligence/ast.py +58 -0
  45. pari_cli-0.1.3/pari/intelligence/cache.py +66 -0
  46. pari_cli-0.1.3/pari/intelligence/ppr.py +67 -0
  47. pari_cli-0.1.3/pari/intelligence/repomap.py +128 -0
  48. pari_cli-0.1.3/pari/intelligence/search.py +90 -0
  49. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari/output/__init__.py +13 -2
  50. pari_cli-0.1.3/pari/output/parser.py +18 -0
  51. pari_cli-0.1.3/pari/output/spooler.py +94 -0
  52. pari_cli-0.1.3/pari/output/truncation.py +29 -0
  53. pari_cli-0.1.3/pari/providers/__init__.py +66 -0
  54. pari_cli-0.1.3/pari/providers/anthropic.py +167 -0
  55. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari/providers/base.py +11 -10
  56. pari_cli-0.1.3/pari/providers/deepseek.py +29 -0
  57. pari_cli-0.1.3/pari/providers/grok.py +29 -0
  58. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari/providers/mock.py +5 -4
  59. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari/providers/ollama.py +11 -5
  60. pari_cli-0.1.3/pari/providers/openai.py +29 -0
  61. pari_cli-0.1.3/pari/providers/openai_compat.py +141 -0
  62. pari_cli-0.1.3/pari/providers/openrouter.py +29 -0
  63. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari/routing/__init__.py +18 -4
  64. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari/routing/anti_verbosity.py +9 -2
  65. pari_cli-0.1.3/pari/routing/classifier.py +33 -0
  66. pari_cli-0.1.3/pari/routing/router.py +135 -0
  67. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari/sandbox/__init__.py +14 -1
  68. pari_cli-0.1.3/pari/sandbox/base.py +386 -0
  69. pari_cli-0.1.3/pari/sandbox/bwrap.py +159 -0
  70. pari_cli-0.1.3/pari/sandbox/dispatcher.py +52 -0
  71. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari/sandbox/e2b.py +10 -5
  72. pari_cli-0.1.3/pari/sandbox/process.py +54 -0
  73. pari_cli-0.1.3/pari/sandbox/seatbelt.py +30 -0
  74. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari/session/__init__.py +21 -4
  75. pari_cli-0.1.3/pari/session/checkpoint.py +43 -0
  76. pari_cli-0.1.3/pari/session/dag.py +56 -0
  77. pari_cli-0.1.3/pari/session/events.py +92 -0
  78. pari_cli-0.1.3/pari/session/logger.py +149 -0
  79. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari/session/undo.py +17 -24
  80. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari/tools/__init__.py +4 -1
  81. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari/tools/base.py +8 -6
  82. pari_cli-0.1.3/pari/tools/bash.py +74 -0
  83. pari_cli-0.1.3/pari/tools/fs.py +159 -0
  84. pari_cli-0.1.3/pari/tools/output_reader.py +75 -0
  85. pari_cli-0.1.3/pari/tools/registry.py +63 -0
  86. pari_cli-0.1.3/pari/tools/search.py +74 -0
  87. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari/tui/__init__.py +9 -4
  88. pari_cli-0.1.3/pari/tui/app.py +157 -0
  89. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari/tui/commands.py +6 -6
  90. pari_cli-0.1.3/pari/tui/completer.py +55 -0
  91. pari_cli-0.1.3/pari/tui/diff_view.py +22 -0
  92. pari_cli-0.1.3/pari/tui/keys.py +46 -0
  93. pari_cli-0.1.3/pari/tui/prompt.py +76 -0
  94. pari_cli-0.1.3/pari/tui/slash.py +130 -0
  95. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari/tui/status.py +34 -5
  96. pari_cli-0.1.3/pari/tui/style.py +27 -0
  97. {pari_cli-0.1.2 → pari_cli-0.1.3/pari_cli.egg-info}/PKG-INFO +6 -3
  98. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari_cli.egg-info/SOURCES.txt +19 -12
  99. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari_cli.egg-info/entry_points.txt +0 -1
  100. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari_cli.egg-info/requires.txt +4 -1
  101. {pari_cli-0.1.2 → pari_cli-0.1.3}/pyproject.toml +52 -3
  102. pari_cli-0.1.2/pari/agent/__init__.py +0 -14
  103. pari_cli-0.1.2/pari/agent/loop.py +0 -35
  104. pari_cli-0.1.2/pari/agent/reflection.py +0 -39
  105. pari_cli-0.1.2/pari/agent/runtime.py +0 -91
  106. pari_cli-0.1.2/pari/agent/subagent.py +0 -34
  107. pari_cli-0.1.2/pari/cli.py +0 -132
  108. pari_cli-0.1.2/pari/context/cache.py +0 -15
  109. pari_cli-0.1.2/pari/context/compaction.py +0 -21
  110. pari_cli-0.1.2/pari/context/epoch.py +0 -32
  111. pari_cli-0.1.2/pari/context/warmer.py +0 -32
  112. pari_cli-0.1.2/pari/edit/cascade.py +0 -22
  113. pari_cli-0.1.2/pari/edit/diff.py +0 -8
  114. pari_cli-0.1.2/pari/edit/dmp.py +0 -15
  115. pari_cli-0.1.2/pari/edit/git_osr.py +0 -14
  116. pari_cli-0.1.2/pari/edit/indenter.py +0 -12
  117. pari_cli-0.1.2/pari/fs/queue.py +0 -22
  118. pari_cli-0.1.2/pari/intelligence/ast.py +0 -25
  119. pari_cli-0.1.2/pari/intelligence/cache.py +0 -21
  120. pari_cli-0.1.2/pari/intelligence/ppr.py +0 -21
  121. pari_cli-0.1.2/pari/intelligence/repomap.py +0 -21
  122. pari_cli-0.1.2/pari/intelligence/search.py +0 -33
  123. pari_cli-0.1.2/pari/output/parser.py +0 -14
  124. pari_cli-0.1.2/pari/output/spooler.py +0 -33
  125. pari_cli-0.1.2/pari/output/truncation.py +0 -20
  126. pari_cli-0.1.2/pari/providers/__init__.py +0 -24
  127. pari_cli-0.1.2/pari/providers/anthropic.py +0 -23
  128. pari_cli-0.1.2/pari/providers/deepseek.py +0 -24
  129. pari_cli-0.1.2/pari/providers/openai.py +0 -23
  130. pari_cli-0.1.2/pari/routing/classifier.py +0 -24
  131. pari_cli-0.1.2/pari/routing/router.py +0 -23
  132. pari_cli-0.1.2/pari/sandbox/base.py +0 -74
  133. pari_cli-0.1.2/pari/sandbox/bwrap.py +0 -39
  134. pari_cli-0.1.2/pari/sandbox/dispatcher.py +0 -31
  135. pari_cli-0.1.2/pari/sandbox/process.py +0 -38
  136. pari_cli-0.1.2/pari/sandbox/seatbelt.py +0 -32
  137. pari_cli-0.1.2/pari/session/checkpoint.py +0 -24
  138. pari_cli-0.1.2/pari/session/dag.py +0 -34
  139. pari_cli-0.1.2/pari/session/events.py +0 -50
  140. pari_cli-0.1.2/pari/session/logger.py +0 -41
  141. pari_cli-0.1.2/pari/tools/bash.py +0 -24
  142. pari_cli-0.1.2/pari/tools/fs.py +0 -53
  143. pari_cli-0.1.2/pari/tools/registry.py +0 -36
  144. pari_cli-0.1.2/pari/tools/search.py +0 -38
  145. pari_cli-0.1.2/pari/tui/app.py +0 -356
  146. pari_cli-0.1.2/pari/tui/diff_view.py +0 -17
  147. pari_cli-0.1.2/tests/test_agent_runtime.py +0 -55
  148. pari_cli-0.1.2/tests/test_context.py +0 -38
  149. pari_cli-0.1.2/tests/test_edit.py +0 -53
  150. pari_cli-0.1.2/tests/test_fs.py +0 -48
  151. pari_cli-0.1.2/tests/test_output.py +0 -38
  152. pari_cli-0.1.2/tests/test_routing.py +0 -21
  153. pari_cli-0.1.2/tests/test_sandbox.py +0 -20
  154. pari_cli-0.1.2/tests/test_search.py +0 -26
  155. pari_cli-0.1.2/tests/test_session.py +0 -41
  156. pari_cli-0.1.2/tests/test_tools.py +0 -41
  157. pari_cli-0.1.2/tests/test_tui.py +0 -103
  158. {pari_cli-0.1.2 → pari_cli-0.1.3}/LICENSE +0 -0
  159. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari_cli.egg-info/dependency_links.txt +0 -0
  160. {pari_cli-0.1.2 → pari_cli-0.1.3}/pari_cli.egg-info/top_level.txt +0 -0
  161. {pari_cli-0.1.2 → 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.2
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
@@ -162,7 +165,7 @@ pip install pari-cli
162
165
  export ANTHROPIC_API_KEY="sk-ant-..."
163
166
 
164
167
  # Launch PARI anywhere in your project workspace
165
- pari
168
+ pari-cli
166
169
  ```
167
170
 
168
171
  ---
@@ -122,7 +122,7 @@ pip install pari-cli
122
122
  export ANTHROPIC_API_KEY="sk-ant-..."
123
123
 
124
124
  # Launch PARI anywhere in your project workspace
125
- pari
125
+ pari-cli
126
126
  ```
127
127
 
128
128
  ---
@@ -1,5 +1,3 @@
1
1
  """PARI — High-performance terminal coding agent."""
2
2
 
3
- __version__ = "0.1.2"
4
-
5
-
3
+ __version__ = "0.1.3"
@@ -6,7 +6,9 @@ Allows running PARI via:
6
6
  """
7
7
 
8
8
  import sys
9
+
9
10
  from pari.cli import main
10
11
 
12
+
11
13
  if __name__ == "__main__":
12
14
  sys.exit(main())
@@ -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
+ )