devpilot-agentic-cli 1.0.0__py3-none-any.whl
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.
- agent/__init__.py +1 -0
- agent/a2a_client.py +94 -0
- agent/a2a_server.py +148 -0
- agent/cli.py +233 -0
- agent/config.py +232 -0
- agent/context.py +182 -0
- agent/history.py +172 -0
- agent/loop.py +102 -0
- agent/mcp_client.py +104 -0
- agent/providers/__init__.py +4 -0
- agent/providers/anthropic_provider.py +169 -0
- agent/providers/base.py +148 -0
- agent/providers/factory.py +35 -0
- agent/providers/openai_provider.py +194 -0
- agent/providers/system_prompt.py +132 -0
- agent/setup_wizard.py +309 -0
- agent/tools/__init__.py +15 -0
- agent/tools/a2a.py +56 -0
- agent/tools/base.py +52 -0
- agent/tools/diagram.py +131 -0
- agent/tools/doc_gen.py +163 -0
- agent/tools/fs.py +411 -0
- agent/tools/git_ops.py +145 -0
- agent/tools/registry.py +219 -0
- agent/tools/search_code.py +120 -0
- agent/tools/shell.py +118 -0
- agent/tools/web_search.py +105 -0
- agent/tui/__init__.py +3 -0
- agent/tui/app.py +557 -0
- agent/ui.py +263 -0
- devpilot_agentic_cli-1.0.0.dist-info/METADATA +288 -0
- devpilot_agentic_cli-1.0.0.dist-info/RECORD +35 -0
- devpilot_agentic_cli-1.0.0.dist-info/WHEEL +5 -0
- devpilot_agentic_cli-1.0.0.dist-info/entry_points.txt +2 -0
- devpilot_agentic_cli-1.0.0.dist-info/top_level.txt +1 -0
agent/config.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agent/config.py
|
|
3
|
+
───────────────
|
|
4
|
+
Central configuration for DevPilot.
|
|
5
|
+
|
|
6
|
+
All settings are read from environment variables (or a .env file).
|
|
7
|
+
API keys are NEVER hardcoded. Missing keys raise a clear ConfigError
|
|
8
|
+
so the user knows exactly what to set before running.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Literal
|
|
17
|
+
|
|
18
|
+
from dotenv import load_dotenv
|
|
19
|
+
|
|
20
|
+
# ── Load .env file if present (safe to call even if file is missing) ─────────
|
|
21
|
+
load_dotenv(dotenv_path=Path(__file__).resolve().parent.parent / ".env", override=False)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ── Custom exception ─────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
class ConfigError(Exception):
|
|
27
|
+
"""Raised when required configuration is missing or invalid."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ── Supported providers ───────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
Provider = Literal["anthropic", "openai"]
|
|
33
|
+
|
|
34
|
+
PROVIDER_DEFAULTS: dict[str, str] = {
|
|
35
|
+
"anthropic": "claude-opus-4-5",
|
|
36
|
+
"openai": "gpt-4o",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# Keys required per provider (checked lazily so we can build/test without keys)
|
|
40
|
+
REQUIRED_ENV_KEYS: dict[str, str] = {
|
|
41
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
42
|
+
"openai": "OPENAI_API_KEY",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# Models that support extended thinking (Anthropic only)
|
|
46
|
+
THINKING_CAPABLE_MODELS: set[str] = {
|
|
47
|
+
"claude-3-7-sonnet-20250219",
|
|
48
|
+
"claude-3-5-sonnet-20241022",
|
|
49
|
+
"claude-opus-4-5",
|
|
50
|
+
"claude-opus-4-20250514",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ── Config dataclass ──────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Config:
|
|
58
|
+
# Provider + model
|
|
59
|
+
provider: Provider
|
|
60
|
+
model: str
|
|
61
|
+
base_url: str | None # Custom endpoint for Ollama / local models
|
|
62
|
+
|
|
63
|
+
# Agentic loop
|
|
64
|
+
max_iterations: int
|
|
65
|
+
|
|
66
|
+
# Safety
|
|
67
|
+
no_confirm: bool
|
|
68
|
+
|
|
69
|
+
# A2A server
|
|
70
|
+
a2a_port: int
|
|
71
|
+
a2a_token: str | None
|
|
72
|
+
|
|
73
|
+
# Workspace
|
|
74
|
+
workdir: str
|
|
75
|
+
|
|
76
|
+
# Extended thinking (Anthropic Claude only)
|
|
77
|
+
extended_thinking: bool
|
|
78
|
+
thinking_budget: int
|
|
79
|
+
|
|
80
|
+
# Feature flags
|
|
81
|
+
web_search_enabled: bool
|
|
82
|
+
memory_enabled: bool
|
|
83
|
+
a2a_enabled: bool
|
|
84
|
+
|
|
85
|
+
# Sessions
|
|
86
|
+
sessions_dir: Path
|
|
87
|
+
|
|
88
|
+
# ── Derived properties ────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def anthropic_api_key(self) -> str | None:
|
|
92
|
+
return os.getenv("ANTHROPIC_API_KEY")
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def openai_api_key(self) -> str | None:
|
|
96
|
+
return os.getenv("OPENAI_API_KEY")
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def active_api_key(self) -> str | None:
|
|
100
|
+
"""Return the API key for the currently selected provider."""
|
|
101
|
+
if self.provider == "anthropic":
|
|
102
|
+
return self.anthropic_api_key
|
|
103
|
+
return self.openai_api_key
|
|
104
|
+
|
|
105
|
+
# ── Validation ────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
def validate_api_key(self) -> None:
|
|
108
|
+
"""
|
|
109
|
+
Raises ConfigError if the active provider's API key is missing.
|
|
110
|
+
Call this right before making the first API request — not at startup —
|
|
111
|
+
so users can explore/test without a key.
|
|
112
|
+
"""
|
|
113
|
+
key_name = REQUIRED_ENV_KEYS[self.provider]
|
|
114
|
+
if not self.active_api_key:
|
|
115
|
+
raise ConfigError(
|
|
116
|
+
f"\n[DevPilot] Missing API key for provider '{self.provider}'.\n"
|
|
117
|
+
f" → Set the environment variable: {key_name}\n"
|
|
118
|
+
f" → Or add it to your .env file (copy .env.example to .env).\n"
|
|
119
|
+
f" → Get an Anthropic key at: https://console.anthropic.com/\n"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if self.extended_thinking:
|
|
123
|
+
if self.provider != "anthropic":
|
|
124
|
+
raise ConfigError("Extended thinking is only supported with the Anthropic provider.")
|
|
125
|
+
if self.thinking_budget < 1000:
|
|
126
|
+
raise ConfigError("thinking_budget must be >= 1000 tokens.")
|
|
127
|
+
# Note: We don't strictly enforce model names since API evolves, but it's good to check
|
|
128
|
+
if not any(self.model.startswith(m) for m in ["claude-3", "claude-opus"]):
|
|
129
|
+
pass # We'll let the API reject it if it's really unsupported
|
|
130
|
+
|
|
131
|
+
# ── Factory ───────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def load(cls) -> "Config":
|
|
135
|
+
"""
|
|
136
|
+
Load configuration from environment variables.
|
|
137
|
+
Raises ConfigError for invalid (not missing) values.
|
|
138
|
+
"""
|
|
139
|
+
# ── Provider ────────────────────────────────────────────────────
|
|
140
|
+
provider_raw = os.getenv("DEVPILOT_PROVIDER", "anthropic").lower()
|
|
141
|
+
if provider_raw not in ("anthropic", "openai"):
|
|
142
|
+
raise ConfigError(
|
|
143
|
+
f"Invalid DEVPILOT_PROVIDER='{provider_raw}'. "
|
|
144
|
+
"Must be 'anthropic' or 'openai'."
|
|
145
|
+
)
|
|
146
|
+
provider: Provider = provider_raw # type: ignore[assignment]
|
|
147
|
+
|
|
148
|
+
# ── Model ───────────────────────────────────────────────────────
|
|
149
|
+
model = os.getenv("DEVPILOT_MODEL") or PROVIDER_DEFAULTS[provider]
|
|
150
|
+
|
|
151
|
+
# ── Base URL ────────────────────────────────────────────────────
|
|
152
|
+
base_url = os.getenv("DEVPILOT_BASE_URL") or None
|
|
153
|
+
|
|
154
|
+
# ── Max iterations ───────────────────────────────────────────────
|
|
155
|
+
try:
|
|
156
|
+
max_iterations = int(os.getenv("DEVPILOT_MAX_ITERATIONS", "50"))
|
|
157
|
+
if max_iterations < 1:
|
|
158
|
+
raise ValueError
|
|
159
|
+
except ValueError:
|
|
160
|
+
raise ConfigError(
|
|
161
|
+
"DEVPILOT_MAX_ITERATIONS must be a positive integer."
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# ── No-confirm flag ─────────────────────────────────────────────
|
|
165
|
+
no_confirm_raw = os.getenv("DEVPILOT_NO_CONFIRM", "false").lower()
|
|
166
|
+
no_confirm = no_confirm_raw in ("true", "1", "yes")
|
|
167
|
+
|
|
168
|
+
# ── A2A port ────────────────────────────────────────────────────
|
|
169
|
+
try:
|
|
170
|
+
a2a_port = int(os.getenv("DEVPILOT_A2A_PORT", "8000"))
|
|
171
|
+
except ValueError:
|
|
172
|
+
raise ConfigError("DEVPILOT_A2A_PORT must be an integer.")
|
|
173
|
+
|
|
174
|
+
# ── A2A token ───────────────────────────────────────────────────
|
|
175
|
+
a2a_token = os.getenv("DEVPILOT_A2A_TOKEN") or None
|
|
176
|
+
|
|
177
|
+
# ── Workspace ───────────────────────────────────────────────────
|
|
178
|
+
workdir = os.getenv("DEVPILOT_WORKDIR", os.getcwd())
|
|
179
|
+
|
|
180
|
+
# ── Extended thinking ───────────────────────────────────────────
|
|
181
|
+
extended_thinking = os.getenv("DEVPILOT_THINKING", "false").lower() in ("true", "1", "yes")
|
|
182
|
+
try:
|
|
183
|
+
thinking_budget = int(os.getenv("DEVPILOT_THINKING_BUDGET", "10000"))
|
|
184
|
+
except ValueError:
|
|
185
|
+
raise ConfigError("DEVPILOT_THINKING_BUDGET must be an integer.")
|
|
186
|
+
|
|
187
|
+
# ── Feature flags ───────────────────────────────────────────────
|
|
188
|
+
web_search_enabled = os.getenv("DEVPILOT_NO_WEB_SEARCH", "false").lower() not in ("true", "1", "yes")
|
|
189
|
+
memory_enabled = os.getenv("DEVPILOT_NO_MEMORY", "false").lower() not in ("true", "1", "yes")
|
|
190
|
+
a2a_enabled = os.getenv("DEVPILOT_NO_A2A", "false").lower() not in ("true", "1", "yes")
|
|
191
|
+
|
|
192
|
+
# ── Sessions dir ────────────────────────────────────────────────
|
|
193
|
+
sessions_dir = Path(
|
|
194
|
+
os.getenv("DEVPILOT_SESSIONS_DIR", ".devpilot_sessions")
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
return cls(
|
|
198
|
+
provider=provider,
|
|
199
|
+
model=model,
|
|
200
|
+
base_url=base_url,
|
|
201
|
+
max_iterations=max_iterations,
|
|
202
|
+
no_confirm=no_confirm,
|
|
203
|
+
a2a_port=a2a_port,
|
|
204
|
+
a2a_token=a2a_token,
|
|
205
|
+
workdir=workdir,
|
|
206
|
+
extended_thinking=extended_thinking,
|
|
207
|
+
thinking_budget=thinking_budget,
|
|
208
|
+
web_search_enabled=web_search_enabled,
|
|
209
|
+
memory_enabled=memory_enabled,
|
|
210
|
+
a2a_enabled=a2a_enabled,
|
|
211
|
+
sessions_dir=sessions_dir,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# ── Display ───────────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
def __str__(self) -> str:
|
|
217
|
+
key_status = "✓ present" if self.active_api_key else "✗ MISSING"
|
|
218
|
+
return (
|
|
219
|
+
f"DevPilot Config\n"
|
|
220
|
+
f" provider : {self.provider}\n"
|
|
221
|
+
f" model : {self.model}\n"
|
|
222
|
+
f" base_url : {self.base_url or '(default)'}\n"
|
|
223
|
+
f" max_iterations: {self.max_iterations}\n"
|
|
224
|
+
f" no_confirm : {self.no_confirm}\n"
|
|
225
|
+
f" a2a_port : {self.a2a_port}\n"
|
|
226
|
+
f" thinking : {self.extended_thinking} (budget: {self.thinking_budget})\n"
|
|
227
|
+
f" web_search : {self.web_search_enabled}\n"
|
|
228
|
+
f" memory : {self.memory_enabled}\n"
|
|
229
|
+
f" a2a : {self.a2a_enabled}\n"
|
|
230
|
+
f" api_key : {key_status}\n"
|
|
231
|
+
f" sessions_dir : {self.sessions_dir}\n"
|
|
232
|
+
)
|
agent/context.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agent/context.py
|
|
3
|
+
────────────────
|
|
4
|
+
RepoContext — tracks which files the model has read this session,
|
|
5
|
+
their content hashes, and the repo structure snapshot.
|
|
6
|
+
|
|
7
|
+
Injected into every system prompt so the model always knows:
|
|
8
|
+
- What it has already read (no redundant re-reads)
|
|
9
|
+
- Whether a file has changed since it last read it
|
|
10
|
+
- The top-level directory structure of the project
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import ast
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RepoContext:
|
|
23
|
+
"""
|
|
24
|
+
Lightweight repo awareness tracker.
|
|
25
|
+
|
|
26
|
+
The loop calls record_read() after every successful read_file call.
|
|
27
|
+
WriteFileTool calls record_write() after every successful write.
|
|
28
|
+
build_context_block() returns a compact text block injected into
|
|
29
|
+
the system prompt at the start of every chat() call.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, workdir: str) -> None:
|
|
33
|
+
self._workdir = Path(workdir).resolve()
|
|
34
|
+
# path (relative str) -> content hash at last read
|
|
35
|
+
self._read_files: dict[str, str] = {}
|
|
36
|
+
# path (relative str) -> content hash at write time
|
|
37
|
+
self._written_files: dict[str, str] = {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ── Recording ─────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
def record_read(self, path: str, content: str) -> None:
|
|
43
|
+
"""Called by ReadFileTool after a successful read."""
|
|
44
|
+
rel = self._rel(path)
|
|
45
|
+
self._read_files[rel] = self._hash(content)
|
|
46
|
+
|
|
47
|
+
def record_write(self, path: str, content: str) -> None:
|
|
48
|
+
"""Called by WriteFileTool after a successful write."""
|
|
49
|
+
rel = self._rel(path)
|
|
50
|
+
self._written_files[rel] = self._hash(content)
|
|
51
|
+
# Also update read cache so model knows current on-disk state
|
|
52
|
+
self._read_files[rel] = self._hash(content)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ── Stale detection ───────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
def is_stale(self, path: str) -> bool:
|
|
58
|
+
"""
|
|
59
|
+
Returns True if the file on disk has changed since last read.
|
|
60
|
+
Used to hint the model it should re-read before writing.
|
|
61
|
+
"""
|
|
62
|
+
rel = self._rel(path)
|
|
63
|
+
if rel not in self._read_files:
|
|
64
|
+
return False
|
|
65
|
+
try:
|
|
66
|
+
full = self._workdir / rel
|
|
67
|
+
current = self._hash(full.read_text(encoding="utf-8", errors="replace"))
|
|
68
|
+
return current != self._read_files[rel]
|
|
69
|
+
except OSError:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
# ── Context block for system prompt ──────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
def build_context_block(self) -> str:
|
|
75
|
+
"""
|
|
76
|
+
Returns a compact text block describing current repo awareness.
|
|
77
|
+
Injected into the system prompt on every chat() call.
|
|
78
|
+
"""
|
|
79
|
+
lines: list[str] = []
|
|
80
|
+
|
|
81
|
+
if self._read_files:
|
|
82
|
+
lines.append("Files you have already read this session (do not re-read unless stale):")
|
|
83
|
+
for rel in sorted(self._read_files):
|
|
84
|
+
stale = self.is_stale(rel)
|
|
85
|
+
tag = " ⚠ MODIFIED ON DISK — re-read before editing" if stale else ""
|
|
86
|
+
lines.append(f" • {rel}{tag}")
|
|
87
|
+
else:
|
|
88
|
+
lines.append("You have not read any files yet this session.")
|
|
89
|
+
|
|
90
|
+
if self._written_files:
|
|
91
|
+
lines.append("\nFiles you have written this session:")
|
|
92
|
+
for rel in sorted(self._written_files):
|
|
93
|
+
lines.append(f" • {rel}")
|
|
94
|
+
|
|
95
|
+
tree = self._build_project_tree()
|
|
96
|
+
if tree:
|
|
97
|
+
lines.append(f"\nProject root ({self._workdir.name}/):")
|
|
98
|
+
lines.extend(f" {entry}" for entry in tree)
|
|
99
|
+
|
|
100
|
+
return "\n".join(lines)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ── Helpers ───────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
def _rel(self, path: str) -> str:
|
|
107
|
+
p = Path(path)
|
|
108
|
+
if p.is_absolute():
|
|
109
|
+
try:
|
|
110
|
+
return str(p.relative_to(self._workdir))
|
|
111
|
+
except ValueError:
|
|
112
|
+
return path
|
|
113
|
+
return str(p)
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _hash(content: str) -> str:
|
|
117
|
+
return hashlib.md5(content.encode("utf-8", errors="replace")).hexdigest()
|
|
118
|
+
|
|
119
|
+
def _extract_signatures(self, path: Path) -> list[str]:
|
|
120
|
+
rel = self._rel(str(path))
|
|
121
|
+
if rel in self._read_files:
|
|
122
|
+
return []
|
|
123
|
+
|
|
124
|
+
sigs = []
|
|
125
|
+
try:
|
|
126
|
+
content = path.read_text(encoding="utf-8", errors="ignore")
|
|
127
|
+
if path.suffix == ".py":
|
|
128
|
+
tree = ast.parse(content)
|
|
129
|
+
for node in tree.body:
|
|
130
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
131
|
+
sigs.append(f" def {node.name}(...)")
|
|
132
|
+
elif isinstance(node, ast.ClassDef):
|
|
133
|
+
sigs.append(f" class {node.name}:")
|
|
134
|
+
for sub_node in node.body:
|
|
135
|
+
if isinstance(sub_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
136
|
+
sigs.append(f" def {sub_node.name}(...)")
|
|
137
|
+
elif path.suffix in (".js", ".ts"):
|
|
138
|
+
for line in content.splitlines():
|
|
139
|
+
line = line.strip()
|
|
140
|
+
if line.startswith("class ") or line.startswith("export class ") or \
|
|
141
|
+
line.startswith("function ") or line.startswith("export function "):
|
|
142
|
+
sigs.append(f" {line}")
|
|
143
|
+
except Exception:
|
|
144
|
+
pass
|
|
145
|
+
return sigs
|
|
146
|
+
|
|
147
|
+
def _build_project_tree(self, max_entries: int = 200) -> list[str]:
|
|
148
|
+
entries: list[str] = []
|
|
149
|
+
ignores = {
|
|
150
|
+
".git", "node_modules", ".venv", "__pycache__", "dist",
|
|
151
|
+
"build", ".next", ".tox", "coverage_html_report", ".devpilot_sessions"
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
def _traverse(directory: Path, prefix: str = "") -> None:
|
|
155
|
+
if len(entries) >= max_entries:
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
for item in sorted(directory.iterdir()):
|
|
160
|
+
if item.name.startswith(".") and item.name not in (".env", ".gitignore", ".github"):
|
|
161
|
+
continue
|
|
162
|
+
if item.name in ignores or item.name.endswith(".egg-info"):
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
if item.is_dir():
|
|
166
|
+
entries.append(f"{prefix}📁 {item.name}/")
|
|
167
|
+
_traverse(item, prefix + " ")
|
|
168
|
+
else:
|
|
169
|
+
entries.append(f"{prefix}📄 {item.name}")
|
|
170
|
+
if item.suffix in (".py", ".js", ".ts"):
|
|
171
|
+
sigs = self._extract_signatures(item)
|
|
172
|
+
for sig in sigs:
|
|
173
|
+
entries.append(f"{prefix}{sig}")
|
|
174
|
+
|
|
175
|
+
if len(entries) >= max_entries:
|
|
176
|
+
entries.append("… (truncated to ~200 items for context size)")
|
|
177
|
+
break
|
|
178
|
+
except OSError:
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
_traverse(self._workdir)
|
|
182
|
+
return entries
|
agent/history.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agent/history.py
|
|
3
|
+
────────────────
|
|
4
|
+
Manages the conversation history and context window.
|
|
5
|
+
|
|
6
|
+
Improvements over original blunt character-count truncation:
|
|
7
|
+
- Smart pruning: large bash/tool outputs are SUMMARISED in place rather
|
|
8
|
+
than the whole message being dropped. The model retains awareness of
|
|
9
|
+
what ran and what happened, just with a shorter representation.
|
|
10
|
+
- Tool results over TOOL_RESULT_TRIM_CHARS are replaced with a one-line
|
|
11
|
+
summary: "[output truncated — N lines, exit code X]"
|
|
12
|
+
- When overall history still exceeds the limit after trimming, oldest
|
|
13
|
+
user↔assistant pairs are dropped (never orphaning tool_use blocks).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
# Overall hard limit in characters (~400k chars ≈ 100k tokens)
|
|
23
|
+
_MAX_CHARS = 100_000 * 4
|
|
24
|
+
|
|
25
|
+
# Individual tool result outputs larger than this get summarised in-place
|
|
26
|
+
# before they're ever stored (≈4k tokens — enough for most command output)
|
|
27
|
+
_TOOL_RESULT_TRIM_CHARS = 16_000
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _summarise_tool_result_message(msg: dict[str, Any]) -> dict[str, Any]:
|
|
31
|
+
"""
|
|
32
|
+
Replace oversized tool_result content blocks with a compact summary.
|
|
33
|
+
Returns a new message dict; the original is not mutated.
|
|
34
|
+
"""
|
|
35
|
+
if msg.get("role") != "user":
|
|
36
|
+
return msg
|
|
37
|
+
content = msg.get("content")
|
|
38
|
+
if not isinstance(content, list):
|
|
39
|
+
return msg
|
|
40
|
+
|
|
41
|
+
new_blocks: list[dict[str, Any]] = []
|
|
42
|
+
changed = False
|
|
43
|
+
for block in content:
|
|
44
|
+
if (
|
|
45
|
+
isinstance(block, dict)
|
|
46
|
+
and block.get("type") == "tool_result"
|
|
47
|
+
and isinstance(block.get("content"), str)
|
|
48
|
+
and len(block["content"]) > _TOOL_RESULT_TRIM_CHARS
|
|
49
|
+
):
|
|
50
|
+
raw: str = block["content"]
|
|
51
|
+
lines = raw.splitlines()
|
|
52
|
+
# Extract exit code if present (run_bash format)
|
|
53
|
+
exit_code_line = next(
|
|
54
|
+
(ln for ln in reversed(lines) if ln.startswith("exit code:")), None
|
|
55
|
+
)
|
|
56
|
+
exit_info = f", {exit_code_line}" if exit_code_line else ""
|
|
57
|
+
summary = (
|
|
58
|
+
f"[output trimmed — {len(lines)} lines, "
|
|
59
|
+
f"{len(raw):,} chars{exit_info}. "
|
|
60
|
+
f"First 20 lines:\n"
|
|
61
|
+
+ "\n".join(lines[:20])
|
|
62
|
+
+ ("\n…" if len(lines) > 20 else "")
|
|
63
|
+
+ "]"
|
|
64
|
+
)
|
|
65
|
+
new_blocks.append({**block, "content": summary})
|
|
66
|
+
changed = True
|
|
67
|
+
else:
|
|
68
|
+
new_blocks.append(block)
|
|
69
|
+
|
|
70
|
+
return {**msg, "content": new_blocks} if changed else msg
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class HistoryManager:
|
|
74
|
+
"""Stores the conversation history and manages session persistence."""
|
|
75
|
+
|
|
76
|
+
def __init__(self) -> None:
|
|
77
|
+
self._messages: list[dict[str, Any]] = []
|
|
78
|
+
|
|
79
|
+
def append(self, message: dict[str, Any]) -> None:
|
|
80
|
+
"""Add a message to history, summarising large tool outputs first."""
|
|
81
|
+
self._messages.append(_summarise_tool_result_message(message))
|
|
82
|
+
self._truncate_if_needed()
|
|
83
|
+
|
|
84
|
+
def extend(self, messages: list[dict[str, Any]]) -> None:
|
|
85
|
+
for msg in messages:
|
|
86
|
+
self._messages.append(_summarise_tool_result_message(msg))
|
|
87
|
+
self._truncate_if_needed()
|
|
88
|
+
|
|
89
|
+
def get_messages(self) -> list[dict[str, Any]]:
|
|
90
|
+
return list(self._messages)
|
|
91
|
+
|
|
92
|
+
# ── Internal helpers ──────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
def _is_tool_result(self, msg: dict[str, Any]) -> bool:
|
|
95
|
+
if msg.get("role") != "user":
|
|
96
|
+
return False
|
|
97
|
+
content = msg.get("content")
|
|
98
|
+
if isinstance(content, list):
|
|
99
|
+
return any(
|
|
100
|
+
isinstance(b, dict) and b.get("type") == "tool_result" for b in content
|
|
101
|
+
)
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
def _is_tool_use(self, msg: dict[str, Any]) -> bool:
|
|
105
|
+
if msg.get("role") != "assistant":
|
|
106
|
+
return False
|
|
107
|
+
content = msg.get("content")
|
|
108
|
+
if isinstance(content, list):
|
|
109
|
+
return any(
|
|
110
|
+
isinstance(b, dict) and b.get("type") == "tool_use" for b in content
|
|
111
|
+
)
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
def _truncate_if_needed(self) -> None:
|
|
115
|
+
"""
|
|
116
|
+
Drop oldest complete exchange units until under _MAX_CHARS.
|
|
117
|
+
|
|
118
|
+
An exchange unit is one of:
|
|
119
|
+
• A plain user message
|
|
120
|
+
• An assistant message + its following tool_result user messages
|
|
121
|
+
|
|
122
|
+
We never drop a tool_use assistant message without also dropping
|
|
123
|
+
its paired tool_result messages, avoiding Anthropic 400 errors.
|
|
124
|
+
"""
|
|
125
|
+
while True:
|
|
126
|
+
total = sum(len(json.dumps(m)) for m in self._messages)
|
|
127
|
+
if total <= _MAX_CHARS or len(self._messages) <= 2:
|
|
128
|
+
break
|
|
129
|
+
|
|
130
|
+
# Find the first droppable unit: a user message that is NOT a
|
|
131
|
+
# tool_result (those must go with their preceding assistant msg).
|
|
132
|
+
drop_end = 0
|
|
133
|
+
for i, msg in enumerate(self._messages):
|
|
134
|
+
if msg.get("role") == "user" and not self._is_tool_result(msg):
|
|
135
|
+
drop_end = i + 1
|
|
136
|
+
# Also drop the following assistant message + its tool results
|
|
137
|
+
j = drop_end
|
|
138
|
+
while j < len(self._messages):
|
|
139
|
+
next_msg = self._messages[j]
|
|
140
|
+
if next_msg.get("role") == "assistant":
|
|
141
|
+
drop_end = j + 1
|
|
142
|
+
j += 1
|
|
143
|
+
# consume paired tool_results
|
|
144
|
+
while j < len(self._messages) and self._is_tool_result(
|
|
145
|
+
self._messages[j]
|
|
146
|
+
):
|
|
147
|
+
drop_end = j + 1
|
|
148
|
+
j += 1
|
|
149
|
+
else:
|
|
150
|
+
break
|
|
151
|
+
break
|
|
152
|
+
|
|
153
|
+
if drop_end == 0:
|
|
154
|
+
# Fallback: drop the very first message
|
|
155
|
+
self._messages.pop(0)
|
|
156
|
+
else:
|
|
157
|
+
del self._messages[:drop_end]
|
|
158
|
+
|
|
159
|
+
# ── Persistence ───────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
def save(self, path: Path | str) -> None:
|
|
162
|
+
p = Path(path)
|
|
163
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
with open(p, "w", encoding="utf-8") as f:
|
|
165
|
+
json.dump(self._messages, f, indent=2)
|
|
166
|
+
|
|
167
|
+
def load(self, path: Path | str) -> None:
|
|
168
|
+
p = Path(path)
|
|
169
|
+
if not p.exists():
|
|
170
|
+
return
|
|
171
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
172
|
+
self._messages = json.load(f)
|
agent/loop.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agent/loop.py
|
|
3
|
+
─────────────
|
|
4
|
+
The core agentic loop.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from agent.config import Config
|
|
10
|
+
from agent.history import HistoryManager
|
|
11
|
+
from agent.providers.base import BaseProvider
|
|
12
|
+
from agent.tools import ToolRegistry
|
|
13
|
+
from agent.ui import UI
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def run_agent_loop(
|
|
17
|
+
provider: BaseProvider,
|
|
18
|
+
registry: ToolRegistry,
|
|
19
|
+
history: HistoryManager,
|
|
20
|
+
config: Config,
|
|
21
|
+
max_iterations: int = 50,
|
|
22
|
+
context=None,
|
|
23
|
+
) -> None:
|
|
24
|
+
"""
|
|
25
|
+
Executes the agentic loop until the model stops returning tool_uses or
|
|
26
|
+
max_iterations is reached.
|
|
27
|
+
|
|
28
|
+
Uses chat_stream() by default so tokens are printed in real time.
|
|
29
|
+
Falls back transparently for providers/modes that don't support streaming
|
|
30
|
+
(e.g. extended thinking).
|
|
31
|
+
"""
|
|
32
|
+
heal_attempts = 0
|
|
33
|
+
|
|
34
|
+
for iteration in range(max_iterations):
|
|
35
|
+
messages = history.get_messages()
|
|
36
|
+
tools = registry.schemas
|
|
37
|
+
|
|
38
|
+
from agent.providers.system_prompt import build_system_prompt
|
|
39
|
+
|
|
40
|
+
system = build_system_prompt(
|
|
41
|
+
repo_context_block=context.build_context_block() if context else ""
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
# chat_stream() prints text tokens live and returns the full response.
|
|
46
|
+
# For extended thinking or providers without streaming it falls back to chat().
|
|
47
|
+
response = await provider.chat_stream(messages, tools, system=system)
|
|
48
|
+
except Exception as e:
|
|
49
|
+
UI.print_error(f"Provider error: {e}")
|
|
50
|
+
break
|
|
51
|
+
|
|
52
|
+
# Append the assistant's message to history
|
|
53
|
+
history.append(response.assistant_message)
|
|
54
|
+
|
|
55
|
+
# UI: render extended thinking if present (Anthropic only; non-streaming path)
|
|
56
|
+
if response.thinking:
|
|
57
|
+
UI.print_thinking_block(response.thinking)
|
|
58
|
+
|
|
59
|
+
# UI: print assistant text only when NOT already streamed live.
|
|
60
|
+
if response.text and not response.streamed_text:
|
|
61
|
+
UI.print_assistant_message(response.text)
|
|
62
|
+
|
|
63
|
+
if not response.has_tool_uses:
|
|
64
|
+
# The model is done
|
|
65
|
+
break
|
|
66
|
+
|
|
67
|
+
should_break_outer = False
|
|
68
|
+
|
|
69
|
+
for tool_use in response.tool_uses:
|
|
70
|
+
UI.print_tool_call(tool_use.name, tool_use.input)
|
|
71
|
+
|
|
72
|
+
tool_result = await registry.execute(tool_use.name, tool_use.input)
|
|
73
|
+
|
|
74
|
+
UI.print_tool_result(tool_use.name, tool_result.output, tool_result.is_error)
|
|
75
|
+
|
|
76
|
+
# Format and append tool result message
|
|
77
|
+
tool_msg = provider.make_tool_result_message(
|
|
78
|
+
tool_use_id=tool_use.id,
|
|
79
|
+
content=tool_result.output,
|
|
80
|
+
is_error=tool_result.is_error,
|
|
81
|
+
)
|
|
82
|
+
history.append(tool_msg)
|
|
83
|
+
|
|
84
|
+
if tool_result.is_error:
|
|
85
|
+
heal_attempts += 1
|
|
86
|
+
if heal_attempts >= 3:
|
|
87
|
+
UI.print_error("Too many consecutive tool errors (>= 3). Aborting loop to prevent infinite retries.")
|
|
88
|
+
should_break_outer = True
|
|
89
|
+
break
|
|
90
|
+
# Inject explicit user prod to fix for this specific tool
|
|
91
|
+
history.append(provider.make_user_message(
|
|
92
|
+
f"Tool '{tool_use.name}' failed with: {tool_result.output}\n"
|
|
93
|
+
"Please analyze the error, fix the underlying issue, and retry."
|
|
94
|
+
))
|
|
95
|
+
else:
|
|
96
|
+
heal_attempts = 0
|
|
97
|
+
|
|
98
|
+
if should_break_outer:
|
|
99
|
+
break
|
|
100
|
+
|
|
101
|
+
else:
|
|
102
|
+
UI.print_error(f"Max iterations ({max_iterations}) reached. Terminating loop.")
|