nexcoder 0.1.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.
- nex/__init__.py +6 -0
- nex/agent.py +623 -0
- nex/api_client.py +194 -0
- nex/cli.py +506 -0
- nex/config.py +168 -0
- nex/context.py +252 -0
- nex/exceptions.py +39 -0
- nex/indexer/__init__.py +16 -0
- nex/indexer/index.py +332 -0
- nex/indexer/parser.py +352 -0
- nex/indexer/scanner.py +191 -0
- nex/memory/__init__.py +15 -0
- nex/memory/decisions.py +131 -0
- nex/memory/errors.py +257 -0
- nex/memory/project.py +158 -0
- nex/planner.py +122 -0
- nex/py.typed +0 -0
- nex/reviewer.py +111 -0
- nex/safety.py +235 -0
- nex/test_runner.py +201 -0
- nex/tools/__init__.py +114 -0
- nex/tools/file_ops.py +89 -0
- nex/tools/git_ops.py +183 -0
- nex/tools/search.py +156 -0
- nex/tools/shell.py +72 -0
- nexcoder-0.1.0.dist-info/METADATA +170 -0
- nexcoder-0.1.0.dist-info/RECORD +30 -0
- nexcoder-0.1.0.dist-info/WHEEL +4 -0
- nexcoder-0.1.0.dist-info/entry_points.txt +2 -0
- nexcoder-0.1.0.dist-info/licenses/LICENSE +21 -0
nex/__init__.py
ADDED
nex/agent.py
ADDED
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
"""Core agent loop — the heart of Nex AI.
|
|
2
|
+
|
|
3
|
+
Follows the agentic REPL pattern:
|
|
4
|
+
1. Assemble context (system prompt + memory + code + errors)
|
|
5
|
+
2. Call Claude API with tool definitions
|
|
6
|
+
3. Parse response: if tool_use blocks, execute the tools
|
|
7
|
+
4. Feed tool results back to Claude
|
|
8
|
+
5. Repeat until text-only response or max iterations
|
|
9
|
+
6. Log errors encountered + fixes applied
|
|
10
|
+
7. If code modified: run tests, show diff, ask to commit
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
from rich.markdown import Markdown
|
|
21
|
+
from rich.panel import Panel
|
|
22
|
+
from rich.prompt import Prompt
|
|
23
|
+
from rich.syntax import Syntax
|
|
24
|
+
|
|
25
|
+
from nex.api_client import AnthropicClient
|
|
26
|
+
from nex.config import NexConfig
|
|
27
|
+
from nex.context import ContextAssembler
|
|
28
|
+
from nex.exceptions import NexError, SafetyError, ToolError
|
|
29
|
+
from nex.memory.errors import ErrorPatternDB
|
|
30
|
+
from nex.memory.project import ProjectMemory
|
|
31
|
+
from nex.safety import SafetyLayer
|
|
32
|
+
from nex.test_runner import TestRunner
|
|
33
|
+
from nex.tools import TOOL_DEFINITIONS, ToolResult
|
|
34
|
+
from nex.tools.file_ops import read_file, write_file
|
|
35
|
+
from nex.tools.git_ops import GitOperations
|
|
36
|
+
from nex.tools.search import search_files
|
|
37
|
+
from nex.tools.shell import run_command
|
|
38
|
+
|
|
39
|
+
console = Console()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class AgentConfig:
|
|
44
|
+
"""Configuration for a single agent run.
|
|
45
|
+
|
|
46
|
+
Attributes:
|
|
47
|
+
project_dir: Project root directory.
|
|
48
|
+
task: The user's task description.
|
|
49
|
+
dry_run: If True, show what would happen without executing.
|
|
50
|
+
max_iterations: Maximum number of tool call iterations.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
project_dir: Path
|
|
54
|
+
task: str
|
|
55
|
+
dry_run: bool = False
|
|
56
|
+
max_iterations: int = 25
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def execute_tool(
|
|
60
|
+
name: str,
|
|
61
|
+
tool_input: dict[str, Any],
|
|
62
|
+
project_dir: Path,
|
|
63
|
+
safety: SafetyLayer,
|
|
64
|
+
dry_run: bool = False,
|
|
65
|
+
) -> tuple[ToolResult, bool]:
|
|
66
|
+
"""Route a tool call to the correct handler.
|
|
67
|
+
|
|
68
|
+
Shared between Agent (single-task) and ChatSession (interactive REPL).
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
name: Name of the tool to execute.
|
|
72
|
+
tool_input: Tool input parameters.
|
|
73
|
+
project_dir: Project root directory.
|
|
74
|
+
safety: Safety layer for command approval.
|
|
75
|
+
dry_run: If True, skip destructive operations.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Tuple of (ToolResult, files_modified_flag).
|
|
79
|
+
"""
|
|
80
|
+
files_modified = False
|
|
81
|
+
try:
|
|
82
|
+
if name == "read_file":
|
|
83
|
+
return await read_file(tool_input["path"], project_dir), False
|
|
84
|
+
|
|
85
|
+
if name == "write_file":
|
|
86
|
+
path = tool_input["path"]
|
|
87
|
+
check = safety.check_file_write(path, project_dir)
|
|
88
|
+
if not check.is_safe:
|
|
89
|
+
if check.requires_approval:
|
|
90
|
+
approved = await safety.request_approval(f"Write to {path}", check.reason or "")
|
|
91
|
+
if not approved:
|
|
92
|
+
return ToolResult(success=False, output="", error="Write denied"), False
|
|
93
|
+
else:
|
|
94
|
+
reason = check.reason or "Write blocked"
|
|
95
|
+
return ToolResult(success=False, output="", error=reason), False
|
|
96
|
+
|
|
97
|
+
result = await write_file(path, tool_input["content"], project_dir)
|
|
98
|
+
if result.success:
|
|
99
|
+
files_modified = True
|
|
100
|
+
return result, files_modified
|
|
101
|
+
|
|
102
|
+
if name == "run_command":
|
|
103
|
+
try:
|
|
104
|
+
await safety.guard_command(tool_input["command"])
|
|
105
|
+
except SafetyError as exc:
|
|
106
|
+
return ToolResult(success=False, output="", error=str(exc)), False
|
|
107
|
+
|
|
108
|
+
if dry_run:
|
|
109
|
+
return (
|
|
110
|
+
ToolResult(
|
|
111
|
+
success=True,
|
|
112
|
+
output=f"[dry-run] Would execute: {tool_input['command']}",
|
|
113
|
+
),
|
|
114
|
+
False,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return await run_command(tool_input["command"], project_dir), False
|
|
118
|
+
|
|
119
|
+
if name == "search_files":
|
|
120
|
+
result = await search_files(
|
|
121
|
+
tool_input["pattern"],
|
|
122
|
+
tool_input.get("path", "."),
|
|
123
|
+
project_dir,
|
|
124
|
+
)
|
|
125
|
+
return result, False
|
|
126
|
+
|
|
127
|
+
if name == "list_directory":
|
|
128
|
+
result = await list_directory(
|
|
129
|
+
tool_input.get("path", "."),
|
|
130
|
+
tool_input.get("depth", 3),
|
|
131
|
+
project_dir,
|
|
132
|
+
)
|
|
133
|
+
return result, False
|
|
134
|
+
|
|
135
|
+
return ToolResult(success=False, output="", error=f"Unknown tool: {name}"), False
|
|
136
|
+
|
|
137
|
+
except Exception as exc:
|
|
138
|
+
return ToolResult(success=False, output="", error=f"Tool error: {exc}"), False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def list_directory(path: str, depth: int, project_dir: Path) -> ToolResult:
|
|
142
|
+
"""List directory contents recursively.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
path: Directory path relative to project root.
|
|
146
|
+
depth: Maximum recursion depth.
|
|
147
|
+
project_dir: Project root directory.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
ToolResult with the directory tree listing.
|
|
151
|
+
"""
|
|
152
|
+
from nex.tools.file_ops import _resolve_safe_path
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
resolved = _resolve_safe_path(path, project_dir)
|
|
156
|
+
except ToolError as exc:
|
|
157
|
+
return ToolResult(success=False, output="", error=str(exc))
|
|
158
|
+
|
|
159
|
+
if not resolved.is_dir():
|
|
160
|
+
return ToolResult(success=False, output="", error=f"Not a directory: {path}")
|
|
161
|
+
|
|
162
|
+
lines: list[str] = []
|
|
163
|
+
_walk_tree(resolved, "", depth, lines)
|
|
164
|
+
return ToolResult(success=True, output="\n".join(lines))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _walk_tree(directory: Path, prefix: str, depth: int, lines: list[str]) -> None:
|
|
168
|
+
"""Recursively build a directory tree listing.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
directory: Current directory to list.
|
|
172
|
+
prefix: Line prefix for indentation.
|
|
173
|
+
depth: Remaining recursion depth.
|
|
174
|
+
lines: Accumulator for output lines.
|
|
175
|
+
"""
|
|
176
|
+
if depth <= 0:
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
entries = sorted(directory.iterdir(), key=lambda e: (not e.is_dir(), e.name))
|
|
181
|
+
except PermissionError:
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
for i, entry in enumerate(entries):
|
|
185
|
+
if entry.name.startswith(".") or entry.name in ("node_modules", "__pycache__", ".nex"):
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
is_last = i == len(entries) - 1
|
|
189
|
+
connector = "\u2514\u2500\u2500 " if is_last else "\u251c\u2500\u2500 "
|
|
190
|
+
lines.append(f"{prefix}{connector}{entry.name}")
|
|
191
|
+
|
|
192
|
+
if entry.is_dir():
|
|
193
|
+
extension = " " if is_last else "\u2502 "
|
|
194
|
+
_walk_tree(entry, prefix + extension, depth - 1, lines)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class Agent:
|
|
198
|
+
"""Core agent loop — orchestrates tools, API calls, and memory."""
|
|
199
|
+
|
|
200
|
+
def __init__(
|
|
201
|
+
self,
|
|
202
|
+
config: AgentConfig,
|
|
203
|
+
api_client: AnthropicClient,
|
|
204
|
+
safety: SafetyLayer,
|
|
205
|
+
nex_config: NexConfig | None = None,
|
|
206
|
+
) -> None:
|
|
207
|
+
"""Initialize the agent.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
config: Agent run configuration.
|
|
211
|
+
api_client: Anthropic API client.
|
|
212
|
+
safety: Safety layer for command approval.
|
|
213
|
+
nex_config: Full Nex configuration (for test runner settings).
|
|
214
|
+
"""
|
|
215
|
+
self._config = config
|
|
216
|
+
self._nex_config = nex_config
|
|
217
|
+
self._client = api_client
|
|
218
|
+
self._safety = safety
|
|
219
|
+
self._project_dir = config.project_dir
|
|
220
|
+
self._messages: list[dict[str, Any]] = []
|
|
221
|
+
self._iteration = 0
|
|
222
|
+
self._files_modified = False
|
|
223
|
+
|
|
224
|
+
async def run(self) -> str:
|
|
225
|
+
"""Execute the agent loop and return the final response.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
The agent's final text response.
|
|
229
|
+
"""
|
|
230
|
+
# Load context
|
|
231
|
+
memory = ProjectMemory(self._project_dir)
|
|
232
|
+
error_db = ErrorPatternDB(self._project_dir)
|
|
233
|
+
assembler = ContextAssembler(self._project_dir)
|
|
234
|
+
|
|
235
|
+
project_memory = memory.load()
|
|
236
|
+
error_patterns = error_db.find_similar(task_summary=self._config.task)
|
|
237
|
+
|
|
238
|
+
# Try to load index for relevant code
|
|
239
|
+
from nex.indexer.index import IndexBuilder
|
|
240
|
+
|
|
241
|
+
builder = IndexBuilder(self._project_dir)
|
|
242
|
+
index = builder.load()
|
|
243
|
+
relevant_code = assembler.select_relevant_code(self._config.task, index)
|
|
244
|
+
|
|
245
|
+
system_prompt = assembler.build_system_prompt(
|
|
246
|
+
project_memory=project_memory,
|
|
247
|
+
error_patterns=error_patterns,
|
|
248
|
+
relevant_code=relevant_code,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# Initial user message
|
|
252
|
+
self._messages = [{"role": "user", "content": self._config.task}]
|
|
253
|
+
|
|
254
|
+
console.print(f"\n[bold]Running task:[/bold] {self._config.task}")
|
|
255
|
+
console.print(f"[dim]Max iterations: {self._config.max_iterations}[/dim]\n")
|
|
256
|
+
|
|
257
|
+
final_response = ""
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
while self._iteration < self._config.max_iterations:
|
|
261
|
+
self._iteration += 1
|
|
262
|
+
console.print(f"[dim]--- Iteration {self._iteration} ---[/dim]")
|
|
263
|
+
|
|
264
|
+
response = await self._client.send_message(
|
|
265
|
+
messages=self._messages,
|
|
266
|
+
system=system_prompt,
|
|
267
|
+
tools=TOOL_DEFINITIONS,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# Show token usage
|
|
271
|
+
console.print(
|
|
272
|
+
f"[dim]Tokens: {response.input_tokens} in / "
|
|
273
|
+
f"{response.output_tokens} out | "
|
|
274
|
+
f"Cost: ${self._client.usage.estimated_cost:.4f}[/dim]"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
# Check for cost warning
|
|
278
|
+
if self._client.usage.estimated_cost > 1.0:
|
|
279
|
+
console.print(
|
|
280
|
+
"[bold yellow]Warning:[/bold yellow] Task has exceeded $1.00 in API costs"
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# Process response
|
|
284
|
+
has_tool_use = any(block.get("type") == "tool_use" for block in response.content)
|
|
285
|
+
|
|
286
|
+
if not has_tool_use:
|
|
287
|
+
# Task complete — extract text
|
|
288
|
+
for block in response.content:
|
|
289
|
+
if block.get("type") == "text":
|
|
290
|
+
final_response = block.get("text", "")
|
|
291
|
+
console.print()
|
|
292
|
+
console.print(
|
|
293
|
+
Panel(
|
|
294
|
+
Markdown(final_response),
|
|
295
|
+
title="[bold green]Task Complete[/bold green]",
|
|
296
|
+
border_style="green",
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
break
|
|
300
|
+
|
|
301
|
+
# Execute tool calls
|
|
302
|
+
assistant_content = response.content
|
|
303
|
+
self._messages.append({"role": "assistant", "content": assistant_content})
|
|
304
|
+
|
|
305
|
+
tool_results: list[dict[str, Any]] = []
|
|
306
|
+
for block in assistant_content:
|
|
307
|
+
if block.get("type") == "tool_use":
|
|
308
|
+
tool_name = block["name"]
|
|
309
|
+
tool_input = block["input"]
|
|
310
|
+
tool_id = block["id"]
|
|
311
|
+
|
|
312
|
+
summary = _summarize_input(tool_input)
|
|
313
|
+
console.print(f" [cyan]Tool:[/cyan] {tool_name}({summary})")
|
|
314
|
+
|
|
315
|
+
result, modified = await execute_tool(
|
|
316
|
+
tool_name,
|
|
317
|
+
tool_input,
|
|
318
|
+
self._project_dir,
|
|
319
|
+
self._safety,
|
|
320
|
+
self._config.dry_run,
|
|
321
|
+
)
|
|
322
|
+
if modified:
|
|
323
|
+
self._files_modified = True
|
|
324
|
+
|
|
325
|
+
if result.success:
|
|
326
|
+
console.print(f" [green]OK[/green] ({len(result.output)} chars)")
|
|
327
|
+
else:
|
|
328
|
+
console.print(f" [red]Error:[/red] {result.error}")
|
|
329
|
+
|
|
330
|
+
content = result.output if result.success else f"Error: {result.error}"
|
|
331
|
+
tool_results.append(
|
|
332
|
+
{
|
|
333
|
+
"type": "tool_result",
|
|
334
|
+
"tool_use_id": tool_id,
|
|
335
|
+
"content": content,
|
|
336
|
+
"is_error": not result.success,
|
|
337
|
+
}
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
self._messages.append({"role": "user", "content": tool_results})
|
|
341
|
+
|
|
342
|
+
else:
|
|
343
|
+
max_iter = self._config.max_iterations
|
|
344
|
+
console.print(f"\n[bold yellow]Reached max iterations ({max_iter})[/bold yellow]")
|
|
345
|
+
final_response = "Task did not complete within the iteration limit."
|
|
346
|
+
|
|
347
|
+
except KeyboardInterrupt:
|
|
348
|
+
console.print("\n[yellow]Agent interrupted by user.[/yellow]")
|
|
349
|
+
final_response = "Task interrupted."
|
|
350
|
+
finally:
|
|
351
|
+
error_db.close()
|
|
352
|
+
|
|
353
|
+
# Post-task: run tests, show diff, and offer to commit
|
|
354
|
+
if self._files_modified:
|
|
355
|
+
tests_passed = await self._run_tests()
|
|
356
|
+
if not tests_passed:
|
|
357
|
+
console.print(
|
|
358
|
+
"[yellow]Tests failed. Review the changes before committing.[/yellow]"
|
|
359
|
+
)
|
|
360
|
+
await self._post_task_git()
|
|
361
|
+
|
|
362
|
+
return final_response
|
|
363
|
+
|
|
364
|
+
async def _run_tests(self) -> bool:
|
|
365
|
+
"""Detect and run the project's test suite.
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
True if tests passed or no test runner was detected.
|
|
369
|
+
"""
|
|
370
|
+
runner = TestRunner(self._project_dir)
|
|
371
|
+
|
|
372
|
+
# Use config override if available
|
|
373
|
+
command = None
|
|
374
|
+
timeout = 120
|
|
375
|
+
if self._nex_config:
|
|
376
|
+
if self._nex_config.test_command:
|
|
377
|
+
command = self._nex_config.test_command
|
|
378
|
+
timeout = self._nex_config.test_timeout
|
|
379
|
+
|
|
380
|
+
if command is None:
|
|
381
|
+
command = runner.detect()
|
|
382
|
+
|
|
383
|
+
if command is None:
|
|
384
|
+
return True
|
|
385
|
+
|
|
386
|
+
console.print(f"\n[bold]Running tests:[/bold] {command}")
|
|
387
|
+
result = await runner.run(command, timeout)
|
|
388
|
+
|
|
389
|
+
if result.success:
|
|
390
|
+
console.print(
|
|
391
|
+
Panel(
|
|
392
|
+
result.output[:2000] if len(result.output) > 2000 else result.output,
|
|
393
|
+
title="[bold green]Tests Passed[/bold green]",
|
|
394
|
+
border_style="green",
|
|
395
|
+
)
|
|
396
|
+
)
|
|
397
|
+
else:
|
|
398
|
+
console.print(
|
|
399
|
+
Panel(
|
|
400
|
+
result.output[:2000] if len(result.output) > 2000 else result.output,
|
|
401
|
+
title="[bold red]Tests Failed[/bold red]",
|
|
402
|
+
border_style="red",
|
|
403
|
+
)
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
return result.success
|
|
407
|
+
|
|
408
|
+
async def _post_task_git(self) -> None:
|
|
409
|
+
"""Show diff and offer to commit after file modifications."""
|
|
410
|
+
try:
|
|
411
|
+
git = GitOperations(self._project_dir)
|
|
412
|
+
if not git.is_repo():
|
|
413
|
+
return
|
|
414
|
+
|
|
415
|
+
diff = git.diff()
|
|
416
|
+
if diff:
|
|
417
|
+
console.print("\n[bold]Changes made:[/bold]")
|
|
418
|
+
console.print(Syntax(diff, "diff", theme="monokai"))
|
|
419
|
+
|
|
420
|
+
answer = Prompt.ask(
|
|
421
|
+
"\n[bold]Commit these changes?[/bold]",
|
|
422
|
+
choices=["y", "n"],
|
|
423
|
+
default="n",
|
|
424
|
+
)
|
|
425
|
+
if answer.lower() == "y":
|
|
426
|
+
msg = Prompt.ask("[bold]Commit message[/bold]")
|
|
427
|
+
if msg:
|
|
428
|
+
git.commit(msg)
|
|
429
|
+
except (ToolError, NexError) as exc:
|
|
430
|
+
console.print(f"[yellow]Git operation failed:[/yellow] {exc}")
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
class ChatSession:
|
|
434
|
+
"""Interactive chat session with persistent message history.
|
|
435
|
+
|
|
436
|
+
Unlike Agent (which runs a single task to completion), ChatSession
|
|
437
|
+
maintains a conversation across multiple user turns, accumulating
|
|
438
|
+
context in its message history.
|
|
439
|
+
"""
|
|
440
|
+
|
|
441
|
+
def __init__(
|
|
442
|
+
self,
|
|
443
|
+
api_client: AnthropicClient,
|
|
444
|
+
system_prompt: str,
|
|
445
|
+
project_dir: Path,
|
|
446
|
+
safety: SafetyLayer,
|
|
447
|
+
dry_run: bool = False,
|
|
448
|
+
max_iterations: int = 25,
|
|
449
|
+
) -> None:
|
|
450
|
+
"""Initialize a chat session.
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
api_client: Anthropic API client.
|
|
454
|
+
system_prompt: Pre-assembled system prompt.
|
|
455
|
+
project_dir: Project root directory.
|
|
456
|
+
safety: Safety layer for command approval.
|
|
457
|
+
dry_run: If True, skip destructive operations.
|
|
458
|
+
max_iterations: Max tool calls per user turn.
|
|
459
|
+
"""
|
|
460
|
+
self._client = api_client
|
|
461
|
+
self._system_prompt = system_prompt
|
|
462
|
+
self._project_dir = project_dir
|
|
463
|
+
self._safety = safety
|
|
464
|
+
self._dry_run = dry_run
|
|
465
|
+
self._max_iterations = max_iterations
|
|
466
|
+
self._messages: list[dict[str, Any]] = []
|
|
467
|
+
self._turn_count = 0
|
|
468
|
+
self._files_modified = False
|
|
469
|
+
|
|
470
|
+
@property
|
|
471
|
+
def messages(self) -> list[dict[str, Any]]:
|
|
472
|
+
"""Return the conversation message history."""
|
|
473
|
+
return self._messages
|
|
474
|
+
|
|
475
|
+
@property
|
|
476
|
+
def turn_count(self) -> int:
|
|
477
|
+
"""Return the number of user turns processed."""
|
|
478
|
+
return self._turn_count
|
|
479
|
+
|
|
480
|
+
@property
|
|
481
|
+
def files_modified(self) -> bool:
|
|
482
|
+
"""Return whether any files have been modified."""
|
|
483
|
+
return self._files_modified
|
|
484
|
+
|
|
485
|
+
async def send(self, user_message: str) -> str:
|
|
486
|
+
"""Process a single user turn, executing tools as needed.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
user_message: The user's message text.
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
The assistant's final text response for this turn.
|
|
493
|
+
"""
|
|
494
|
+
self._turn_count += 1
|
|
495
|
+
self._messages.append({"role": "user", "content": user_message})
|
|
496
|
+
|
|
497
|
+
iterations = 0
|
|
498
|
+
final_text = ""
|
|
499
|
+
|
|
500
|
+
while iterations < self._max_iterations:
|
|
501
|
+
iterations += 1
|
|
502
|
+
|
|
503
|
+
response = await self._client.send_message(
|
|
504
|
+
messages=self._messages,
|
|
505
|
+
system=self._system_prompt,
|
|
506
|
+
tools=TOOL_DEFINITIONS,
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
# Show token usage
|
|
510
|
+
console.print(
|
|
511
|
+
f"[dim]Tokens: {response.input_tokens} in / "
|
|
512
|
+
f"{response.output_tokens} out | "
|
|
513
|
+
f"Cost: ${self._client.usage.estimated_cost:.4f}[/dim]"
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
# Cost warnings
|
|
517
|
+
cost = self._client.usage.estimated_cost
|
|
518
|
+
if cost > 5.0:
|
|
519
|
+
console.print(
|
|
520
|
+
"[bold red]Warning:[/bold red] Session has exceeded $5.00 in API costs"
|
|
521
|
+
)
|
|
522
|
+
elif cost > 1.0:
|
|
523
|
+
console.print(
|
|
524
|
+
"[bold yellow]Warning:[/bold yellow] Session has exceeded $1.00 in API costs"
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
has_tool_use = any(block.get("type") == "tool_use" for block in response.content)
|
|
528
|
+
|
|
529
|
+
if not has_tool_use:
|
|
530
|
+
# Extract final text
|
|
531
|
+
for block in response.content:
|
|
532
|
+
if block.get("type") == "text":
|
|
533
|
+
final_text = block.get("text", "")
|
|
534
|
+
self._messages.append({"role": "assistant", "content": response.content})
|
|
535
|
+
break
|
|
536
|
+
|
|
537
|
+
# Execute tool calls
|
|
538
|
+
assistant_content = response.content
|
|
539
|
+
self._messages.append({"role": "assistant", "content": assistant_content})
|
|
540
|
+
|
|
541
|
+
tool_results: list[dict[str, Any]] = []
|
|
542
|
+
for block in assistant_content:
|
|
543
|
+
if block.get("type") == "tool_use":
|
|
544
|
+
tool_name = block["name"]
|
|
545
|
+
tool_input = block["input"]
|
|
546
|
+
tool_id = block["id"]
|
|
547
|
+
|
|
548
|
+
summary = _summarize_input(tool_input)
|
|
549
|
+
console.print(f" [cyan]Tool:[/cyan] {tool_name}({summary})")
|
|
550
|
+
|
|
551
|
+
result, modified = await execute_tool(
|
|
552
|
+
tool_name,
|
|
553
|
+
tool_input,
|
|
554
|
+
self._project_dir,
|
|
555
|
+
self._safety,
|
|
556
|
+
self._dry_run,
|
|
557
|
+
)
|
|
558
|
+
if modified:
|
|
559
|
+
self._files_modified = True
|
|
560
|
+
|
|
561
|
+
if result.success:
|
|
562
|
+
console.print(f" [green]OK[/green] ({len(result.output)} chars)")
|
|
563
|
+
else:
|
|
564
|
+
console.print(f" [red]Error:[/red] {result.error}")
|
|
565
|
+
|
|
566
|
+
content = result.output if result.success else f"Error: {result.error}"
|
|
567
|
+
tool_results.append(
|
|
568
|
+
{
|
|
569
|
+
"type": "tool_result",
|
|
570
|
+
"tool_use_id": tool_id,
|
|
571
|
+
"content": content,
|
|
572
|
+
"is_error": not result.success,
|
|
573
|
+
}
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
self._messages.append({"role": "user", "content": tool_results})
|
|
577
|
+
|
|
578
|
+
return final_text
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
async def run_task(task: str, config: NexConfig) -> None:
|
|
582
|
+
"""Entry point called by the CLI to run a task.
|
|
583
|
+
|
|
584
|
+
Args:
|
|
585
|
+
task: The user's task description.
|
|
586
|
+
config: Nex configuration.
|
|
587
|
+
"""
|
|
588
|
+
api_key = config.api_key
|
|
589
|
+
client = AnthropicClient(api_key=api_key, default_model=config.model)
|
|
590
|
+
safety = SafetyLayer(dry_run=config.dry_run)
|
|
591
|
+
|
|
592
|
+
agent_config = AgentConfig(
|
|
593
|
+
project_dir=config.project_dir,
|
|
594
|
+
task=task,
|
|
595
|
+
dry_run=config.dry_run,
|
|
596
|
+
max_iterations=config.max_iterations,
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
agent = Agent(config=agent_config, api_client=client, safety=safety, nex_config=config)
|
|
600
|
+
|
|
601
|
+
try:
|
|
602
|
+
await agent.run()
|
|
603
|
+
finally:
|
|
604
|
+
await client.close()
|
|
605
|
+
|
|
606
|
+
# Print final cost summary
|
|
607
|
+
console.print(
|
|
608
|
+
f"\n[dim]Total cost: ${client.usage.estimated_cost:.4f} "
|
|
609
|
+
f"({client.usage.total_input} input + {client.usage.total_output} output tokens)[/dim]"
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _summarize_input(tool_input: dict[str, Any]) -> str:
|
|
614
|
+
"""Create a short summary of tool input for display."""
|
|
615
|
+
parts: list[str] = []
|
|
616
|
+
for key, value in tool_input.items():
|
|
617
|
+
if isinstance(value, str) and len(value) > 50:
|
|
618
|
+
parts.append(f'{key}="{value[:47]}..."')
|
|
619
|
+
elif isinstance(value, str):
|
|
620
|
+
parts.append(f'{key}="{value}"')
|
|
621
|
+
else:
|
|
622
|
+
parts.append(f"{key}={value}")
|
|
623
|
+
return ", ".join(parts)
|