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 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