agentcode-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.py +442 -0
- agentcode_cli-1.0.0.dist-info/METADATA +303 -0
- agentcode_cli-1.0.0.dist-info/RECORD +12 -0
- agentcode_cli-1.0.0.dist-info/WHEEL +5 -0
- agentcode_cli-1.0.0.dist-info/entry_points.txt +2 -0
- agentcode_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- agentcode_cli-1.0.0.dist-info/top_level.txt +6 -0
- cli.py +627 -0
- mcp_client.py +194 -0
- router.py +298 -0
- settings.py +185 -0
- tools.py +672 -0
agent.py
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
AgentCode — Brain module.
|
|
4
|
+
|
|
5
|
+
Provides the agentic loop, conversation management, tool execution,
|
|
6
|
+
and AGENTCODE.md project config support.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import json
|
|
11
|
+
import subprocess
|
|
12
|
+
import threading
|
|
13
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import litellm
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.markdown import Markdown
|
|
20
|
+
|
|
21
|
+
from tools import TOOL_DEFINITIONS, execute_tool
|
|
22
|
+
from router import ModelRouter, display_routing_decision
|
|
23
|
+
from mcp_client import MCPManager
|
|
24
|
+
from settings import Settings
|
|
25
|
+
|
|
26
|
+
console = Console()
|
|
27
|
+
|
|
28
|
+
# Tools that require explicit user permission before running
|
|
29
|
+
WRITE_TOOLS = {"write_file", "edit_file", "run_command", "git_commit", "git_branch", "git_push"}
|
|
30
|
+
|
|
31
|
+
SYSTEM_PROMPT = """\
|
|
32
|
+
You are AgentCode, an expert software engineering assistant running in the terminal.
|
|
33
|
+
|
|
34
|
+
You are working in the directory: {cwd}
|
|
35
|
+
|
|
36
|
+
You have access to tools to read files, write files, edit files, run commands, \
|
|
37
|
+
search for files, and search for text. Use them to help the user with their coding tasks.
|
|
38
|
+
|
|
39
|
+
Guidelines:
|
|
40
|
+
- Always read a file before editing it.
|
|
41
|
+
- Prefer edit_file over write_file for modifying existing files.
|
|
42
|
+
- Break complex tasks into steps and explain what you're doing.
|
|
43
|
+
- When you encounter errors, diagnose the root cause before applying a fix.
|
|
44
|
+
- Be concise but thorough.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ── Config ────────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class AgentConfig:
|
|
52
|
+
model: str = "claude-sonnet-4-6"
|
|
53
|
+
auto_approve: bool = False
|
|
54
|
+
project_dir: str = field(default_factory=os.getcwd)
|
|
55
|
+
max_iterations: int = field(
|
|
56
|
+
default_factory=lambda: int(os.environ.get("AGENTCODE_MAX_ITERATIONS", "25"))
|
|
57
|
+
)
|
|
58
|
+
router: ModelRouter | None = None
|
|
59
|
+
mcp_manager: MCPManager | None = None
|
|
60
|
+
settings: Settings | None = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ── Conversation ──────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
class Conversation:
|
|
66
|
+
def __init__(self, system: str = ""):
|
|
67
|
+
self.system = system
|
|
68
|
+
self.messages: list[dict] = []
|
|
69
|
+
|
|
70
|
+
def token_estimate(self) -> int:
|
|
71
|
+
"""Rough token estimate based on character count (~4 chars/token)."""
|
|
72
|
+
total = len(self.system)
|
|
73
|
+
for msg in self.messages:
|
|
74
|
+
content = msg.get("content") or ""
|
|
75
|
+
if isinstance(content, list):
|
|
76
|
+
for block in content:
|
|
77
|
+
if isinstance(block, dict):
|
|
78
|
+
total += len(str(block.get("text", "") or block.get("content", "")))
|
|
79
|
+
else:
|
|
80
|
+
total += len(str(content))
|
|
81
|
+
return total // 4
|
|
82
|
+
|
|
83
|
+
def save(self, path: Path):
|
|
84
|
+
path.write_text(json.dumps(self.messages, indent=2))
|
|
85
|
+
|
|
86
|
+
def load(self, path: Path):
|
|
87
|
+
if path.exists():
|
|
88
|
+
self.messages = json.loads(path.read_text())
|
|
89
|
+
|
|
90
|
+
def compact(self, max_tokens: int = 80_000, model: str | None = None):
|
|
91
|
+
"""Summarize old messages to reduce context. Falls back to dropping if no model given."""
|
|
92
|
+
if not self.messages:
|
|
93
|
+
return
|
|
94
|
+
if max_tokens > 0 and self.token_estimate() <= max_tokens:
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
keep = 6
|
|
98
|
+
if len(self.messages) <= keep:
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
to_summarize = self.messages[:-keep]
|
|
102
|
+
recent = self.messages[-keep:]
|
|
103
|
+
summary = self._summarize(to_summarize, model) if model else (
|
|
104
|
+
f"[{len(to_summarize)} earlier messages were compacted to save context.]"
|
|
105
|
+
)
|
|
106
|
+
self.messages = [
|
|
107
|
+
{"role": "user", "content": summary},
|
|
108
|
+
{"role": "assistant", "content": "Understood. Continuing from the recent context."},
|
|
109
|
+
*recent,
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
def _summarize(self, messages: list[dict], model: str) -> str:
|
|
113
|
+
try:
|
|
114
|
+
parts = []
|
|
115
|
+
for m in messages:
|
|
116
|
+
role = m.get("role", "")
|
|
117
|
+
content = m.get("content") or ""
|
|
118
|
+
if isinstance(content, list):
|
|
119
|
+
content = " ".join(
|
|
120
|
+
str(b.get("text", "")) for b in content if isinstance(b, dict)
|
|
121
|
+
)
|
|
122
|
+
if role in ("user", "assistant") and content:
|
|
123
|
+
parts.append(f"{role.upper()}: {str(content)[:500]}")
|
|
124
|
+
if not parts:
|
|
125
|
+
return f"[{len(messages)} earlier messages were compacted.]"
|
|
126
|
+
resp = litellm.completion(
|
|
127
|
+
model=model,
|
|
128
|
+
messages=[{"role": "user", "content": (
|
|
129
|
+
"Summarize this conversation in 3-5 sentences, preserving key decisions, "
|
|
130
|
+
"file names, and code changes:\n\n" + "\n".join(parts[:50])
|
|
131
|
+
)}],
|
|
132
|
+
max_tokens=300,
|
|
133
|
+
)
|
|
134
|
+
return f"[Context summary: {resp.choices[0].message.content}]"
|
|
135
|
+
except Exception:
|
|
136
|
+
return f"[{len(messages)} earlier messages were compacted to save context.]"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ── Hooks ─────────────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
def load_hooks(project_dir: str, settings: Settings | None = None) -> dict:
|
|
142
|
+
hooks: dict = {}
|
|
143
|
+
# settings.hooks has lowest priority
|
|
144
|
+
if settings and settings.hooks:
|
|
145
|
+
hooks.update(settings.hooks)
|
|
146
|
+
# hooks.json files override settings hooks
|
|
147
|
+
for path in [Path.home() / ".agentcode" / "hooks.json",
|
|
148
|
+
Path(project_dir) / ".agentcode" / "hooks.json"]:
|
|
149
|
+
if path.exists():
|
|
150
|
+
try:
|
|
151
|
+
hooks.update(json.loads(path.read_text()))
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
return hooks
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _run_hook(cmd: str, tool_name: str, args: dict) -> None:
|
|
158
|
+
env = os.environ.copy()
|
|
159
|
+
env["AGENTCODE_TOOL"] = tool_name
|
|
160
|
+
for key, val in args.items():
|
|
161
|
+
env[f"AGENTCODE_{key.upper()}"] = str(val)
|
|
162
|
+
try:
|
|
163
|
+
subprocess.run(cmd, shell=True, env=env, timeout=10, capture_output=True)
|
|
164
|
+
except Exception:
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ── Permission prompt ─────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
def _ask_permission(tool_name: str, args: dict) -> bool:
|
|
171
|
+
"""Ask the user to approve a write/execute tool call. Returns True if approved."""
|
|
172
|
+
console.print(f"\n[bold yellow]⚡ Tool request:[/bold yellow] [bold]{tool_name}[/bold]")
|
|
173
|
+
for key, val in args.items():
|
|
174
|
+
val_str = str(val)
|
|
175
|
+
if len(val_str) > 300:
|
|
176
|
+
val_str = val_str[:300] + "..."
|
|
177
|
+
console.print(f" [dim]{key}:[/dim] {val_str}")
|
|
178
|
+
try:
|
|
179
|
+
answer = console.input(" [bold]Allow? [y/N][/bold] ").strip().lower()
|
|
180
|
+
return answer in ("y", "yes")
|
|
181
|
+
except (EOFError, KeyboardInterrupt):
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ── Permission helpers ────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
def _is_denied(tool_name: str, config: "AgentConfig") -> bool:
|
|
188
|
+
"""Return True if the tool is explicitly blocked by settings."""
|
|
189
|
+
return bool(config.settings and tool_name in config.settings.permissions.deny)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _get_permission(tool_name: str, config: "AgentConfig") -> str:
|
|
193
|
+
"""
|
|
194
|
+
Return 'allow', 'deny', or 'ask' for a built-in tool call.
|
|
195
|
+
|
|
196
|
+
Priority:
|
|
197
|
+
deny list → auto_approve_all (CLI or settings) → auto_approve list → ask
|
|
198
|
+
Falls back to the old WRITE_TOOLS behaviour when no settings are loaded.
|
|
199
|
+
"""
|
|
200
|
+
s = config.settings
|
|
201
|
+
|
|
202
|
+
if s and tool_name in s.permissions.deny:
|
|
203
|
+
return "deny"
|
|
204
|
+
|
|
205
|
+
if config.auto_approve or (s and s.permissions.auto_approve_all):
|
|
206
|
+
return "allow"
|
|
207
|
+
|
|
208
|
+
if s:
|
|
209
|
+
return "allow" if tool_name in s.permissions.auto_approve else "ask"
|
|
210
|
+
|
|
211
|
+
# Backward-compat fallback: WRITE_TOOLS ask, everything else auto
|
|
212
|
+
return "ask" if tool_name in WRITE_TOOLS else "allow"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ── Subagents ─────────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
_print_lock = threading.Lock()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _run_subagents(tasks: list[str], config: AgentConfig, parent_silent: bool = False) -> str:
|
|
221
|
+
"""Run multiple agent loops in parallel and return their combined results."""
|
|
222
|
+
if not tasks:
|
|
223
|
+
return "Error: no tasks provided."
|
|
224
|
+
|
|
225
|
+
max_workers = min(len(tasks), 5)
|
|
226
|
+
results: dict[int, str] = {}
|
|
227
|
+
|
|
228
|
+
if not parent_silent:
|
|
229
|
+
console.print(f"\n[dim] ↳ Spawning {len(tasks)} subagent(s) in parallel...[/dim]")
|
|
230
|
+
|
|
231
|
+
def run_one(idx: int, task: str) -> tuple[int, str]:
|
|
232
|
+
sub_conv = Conversation(system=build_system_prompt(config.project_dir))
|
|
233
|
+
result = run_agent_loop(task, sub_conv, config, silent=True)
|
|
234
|
+
if not parent_silent:
|
|
235
|
+
with _print_lock:
|
|
236
|
+
console.print(f" [dim]✓ Subagent {idx + 1}/{len(tasks)} done[/dim]")
|
|
237
|
+
return idx, result
|
|
238
|
+
|
|
239
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
240
|
+
futures = {executor.submit(run_one, i, task): i for i, task in enumerate(tasks)}
|
|
241
|
+
for future in as_completed(futures):
|
|
242
|
+
idx, result = future.result()
|
|
243
|
+
results[idx] = result
|
|
244
|
+
|
|
245
|
+
parts = []
|
|
246
|
+
for i, task in enumerate(tasks):
|
|
247
|
+
parts.append(f"**Subagent {i + 1}:** {task}\n\n{results[i]}")
|
|
248
|
+
return "\n\n---\n\n".join(parts)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ── Agentic loop ──────────────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
def run_agent_loop(
|
|
254
|
+
user_input: str,
|
|
255
|
+
conversation: Conversation,
|
|
256
|
+
config: AgentConfig,
|
|
257
|
+
silent: bool = False,
|
|
258
|
+
) -> str:
|
|
259
|
+
"""
|
|
260
|
+
Add user_input to conversation, stream the LLM response, handle tool calls
|
|
261
|
+
in a loop, and return the final text response.
|
|
262
|
+
|
|
263
|
+
silent=True suppresses all console output (used by subagents).
|
|
264
|
+
"""
|
|
265
|
+
conversation.messages.append({"role": "user", "content": user_input})
|
|
266
|
+
|
|
267
|
+
router = config.router
|
|
268
|
+
if router:
|
|
269
|
+
router.cost_tracker.begin_turn()
|
|
270
|
+
model, tier, reason = router.route(user_input)
|
|
271
|
+
if not silent:
|
|
272
|
+
display_routing_decision(model, tier, reason, router)
|
|
273
|
+
else:
|
|
274
|
+
model = config.model
|
|
275
|
+
|
|
276
|
+
conversation.compact(max_tokens=80_000, model=model)
|
|
277
|
+
hooks = load_hooks(config.project_dir, config.settings)
|
|
278
|
+
|
|
279
|
+
mcp = config.mcp_manager
|
|
280
|
+
all_tools = TOOL_DEFINITIONS + (mcp.get_tool_definitions() if mcp else [])
|
|
281
|
+
|
|
282
|
+
for _ in range(config.max_iterations):
|
|
283
|
+
stream = litellm.completion(
|
|
284
|
+
model=model,
|
|
285
|
+
messages=[{"role": "system", "content": conversation.system}, *conversation.messages],
|
|
286
|
+
tools=all_tools,
|
|
287
|
+
tool_choice="auto",
|
|
288
|
+
stream=True,
|
|
289
|
+
stream_options={"include_usage": True},
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
full_text = ""
|
|
293
|
+
tool_calls_accum: dict[int, dict] = {} # index → {id, name, arguments}
|
|
294
|
+
usage = None
|
|
295
|
+
|
|
296
|
+
if not silent:
|
|
297
|
+
console.print()
|
|
298
|
+
|
|
299
|
+
for chunk in stream:
|
|
300
|
+
# Capture usage from the final chunk
|
|
301
|
+
if hasattr(chunk, "usage") and chunk.usage:
|
|
302
|
+
usage = chunk.usage
|
|
303
|
+
|
|
304
|
+
if not chunk.choices:
|
|
305
|
+
continue
|
|
306
|
+
|
|
307
|
+
delta = chunk.choices[0].delta
|
|
308
|
+
|
|
309
|
+
# Stream text tokens live
|
|
310
|
+
if delta.content:
|
|
311
|
+
full_text += delta.content
|
|
312
|
+
if not silent:
|
|
313
|
+
console.print(delta.content, end="", markup=False, highlight=False)
|
|
314
|
+
|
|
315
|
+
# Accumulate tool call chunks (arguments arrive in pieces)
|
|
316
|
+
if delta.tool_calls:
|
|
317
|
+
for tc in delta.tool_calls:
|
|
318
|
+
idx = tc.index
|
|
319
|
+
if idx not in tool_calls_accum:
|
|
320
|
+
tool_calls_accum[idx] = {"id": "", "name": "", "arguments": ""}
|
|
321
|
+
if tc.id:
|
|
322
|
+
tool_calls_accum[idx]["id"] = tc.id
|
|
323
|
+
if tc.function:
|
|
324
|
+
if tc.function.name:
|
|
325
|
+
tool_calls_accum[idx]["name"] += tc.function.name
|
|
326
|
+
if tc.function.arguments:
|
|
327
|
+
tool_calls_accum[idx]["arguments"] += tc.function.arguments
|
|
328
|
+
|
|
329
|
+
if not silent:
|
|
330
|
+
console.print()
|
|
331
|
+
|
|
332
|
+
# Record cost from usage in final chunk
|
|
333
|
+
if router and usage:
|
|
334
|
+
try:
|
|
335
|
+
cost = litellm.completion_cost(
|
|
336
|
+
model=model,
|
|
337
|
+
prompt_tokens=usage.prompt_tokens or 0,
|
|
338
|
+
completion_tokens=usage.completion_tokens or 0,
|
|
339
|
+
)
|
|
340
|
+
except Exception:
|
|
341
|
+
cost = 0.0
|
|
342
|
+
router.cost_tracker.record(
|
|
343
|
+
model,
|
|
344
|
+
usage.prompt_tokens or 0,
|
|
345
|
+
usage.completion_tokens or 0,
|
|
346
|
+
cost,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# Pure text response — done
|
|
350
|
+
if not tool_calls_accum:
|
|
351
|
+
conversation.messages.append({"role": "assistant", "content": full_text})
|
|
352
|
+
return full_text
|
|
353
|
+
|
|
354
|
+
# Append assistant turn with accumulated tool calls
|
|
355
|
+
conversation.messages.append({
|
|
356
|
+
"role": "assistant",
|
|
357
|
+
"content": full_text or "",
|
|
358
|
+
"tool_calls": [
|
|
359
|
+
{
|
|
360
|
+
"id": tc["id"],
|
|
361
|
+
"type": "function",
|
|
362
|
+
"function": {"name": tc["name"], "arguments": tc["arguments"]},
|
|
363
|
+
}
|
|
364
|
+
for tc in tool_calls_accum.values()
|
|
365
|
+
],
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
# Execute each tool call and append results
|
|
369
|
+
for tc in tool_calls_accum.values():
|
|
370
|
+
tool_name = tc["name"]
|
|
371
|
+
try:
|
|
372
|
+
args = json.loads(tc["arguments"])
|
|
373
|
+
except json.JSONDecodeError:
|
|
374
|
+
args = {}
|
|
375
|
+
|
|
376
|
+
pre_hook = hooks.get(f"pre_{tool_name}") or hooks.get("pre_tool")
|
|
377
|
+
if pre_hook:
|
|
378
|
+
_run_hook(pre_hook, tool_name, args)
|
|
379
|
+
|
|
380
|
+
if tool_name == "spawn_subagents":
|
|
381
|
+
if _is_denied(tool_name, config):
|
|
382
|
+
result = f"Tool '{tool_name}' is blocked by settings (permissions.deny)."
|
|
383
|
+
else:
|
|
384
|
+
result = _run_subagents(args.get("tasks", []), config, silent)
|
|
385
|
+
elif mcp and mcp.is_mcp_tool(tool_name):
|
|
386
|
+
if _is_denied(tool_name, config):
|
|
387
|
+
result = f"Tool '{tool_name}' is blocked by settings (permissions.deny)."
|
|
388
|
+
else:
|
|
389
|
+
result = mcp.call_tool(tool_name, args)
|
|
390
|
+
else:
|
|
391
|
+
perm = _get_permission(tool_name, config)
|
|
392
|
+
if perm == "deny":
|
|
393
|
+
result = f"Tool '{tool_name}' is blocked by settings (permissions.deny)."
|
|
394
|
+
elif perm == "ask":
|
|
395
|
+
approved = _ask_permission(tool_name, args)
|
|
396
|
+
result = execute_tool(tool_name, args) if approved else f"Tool '{tool_name}' denied by user."
|
|
397
|
+
else:
|
|
398
|
+
result = execute_tool(tool_name, args)
|
|
399
|
+
|
|
400
|
+
post_hook = hooks.get(f"post_{tool_name}") or hooks.get("post_tool")
|
|
401
|
+
if post_hook:
|
|
402
|
+
_run_hook(post_hook, tool_name, args)
|
|
403
|
+
|
|
404
|
+
conversation.messages.append({
|
|
405
|
+
"role": "tool",
|
|
406
|
+
"tool_call_id": tc["id"],
|
|
407
|
+
"content": result,
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
return "Reached max iterations. The task may be incomplete."
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# ── AGENTCODE.md support ──────────────────────────────────────────────────────
|
|
414
|
+
|
|
415
|
+
def load_project_config(project_dir: str) -> dict:
|
|
416
|
+
"""
|
|
417
|
+
Load AGENTCODE.md from the project directory and/or ~/.agentcode/AGENTCODE.md.
|
|
418
|
+
Returns a dict with project_path, global_path, and combined content.
|
|
419
|
+
"""
|
|
420
|
+
result: dict = {"project_path": None, "global_path": None, "combined": ""}
|
|
421
|
+
parts: list[str] = []
|
|
422
|
+
|
|
423
|
+
project_file = Path(project_dir) / "AGENTCODE.md"
|
|
424
|
+
if project_file.exists():
|
|
425
|
+
result["project_path"] = str(project_file)
|
|
426
|
+
parts.append(project_file.read_text())
|
|
427
|
+
|
|
428
|
+
global_file = Path.home() / ".agentcode" / "AGENTCODE.md"
|
|
429
|
+
if global_file.exists():
|
|
430
|
+
result["global_path"] = str(global_file)
|
|
431
|
+
parts.append(global_file.read_text())
|
|
432
|
+
|
|
433
|
+
result["combined"] = "\n\n".join(parts)
|
|
434
|
+
return result
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def build_system_prompt(project_dir: str, agentcode_content: str = "") -> str:
|
|
438
|
+
"""Build the system prompt, appending AGENTCODE.md instructions if present."""
|
|
439
|
+
base = SYSTEM_PROMPT.format(cwd=project_dir)
|
|
440
|
+
if agentcode_content.strip():
|
|
441
|
+
base += f"\n\n## Project Instructions (from AGENTCODE.md)\n\n{agentcode_content}"
|
|
442
|
+
return base
|