nexcoder 0.1.3__tar.gz → 0.1.5__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 (45) hide show
  1. {nexcoder-0.1.3 → nexcoder-0.1.5}/PKG-INFO +1 -1
  2. {nexcoder-0.1.3 → nexcoder-0.1.5}/pyproject.toml +1 -1
  3. {nexcoder-0.1.3 → nexcoder-0.1.5}/src/nex/__init__.py +3 -1
  4. {nexcoder-0.1.3 → nexcoder-0.1.5}/src/nex/agent.py +2 -3
  5. {nexcoder-0.1.3 → nexcoder-0.1.5}/src/nex/api_client.py +42 -11
  6. {nexcoder-0.1.3 → nexcoder-0.1.5}/src/nex/cli.py +1 -1
  7. {nexcoder-0.1.3 → nexcoder-0.1.5}/src/nex/config.py +1 -1
  8. {nexcoder-0.1.3 → nexcoder-0.1.5}/src/nex/planner.py +17 -3
  9. {nexcoder-0.1.3 → nexcoder-0.1.5}/.github/workflows/ci.yml +0 -0
  10. {nexcoder-0.1.3 → nexcoder-0.1.5}/.github/workflows/publish.yml +0 -0
  11. {nexcoder-0.1.3 → nexcoder-0.1.5}/.gitignore +0 -0
  12. {nexcoder-0.1.3 → nexcoder-0.1.5}/CHANGELOG.md +0 -0
  13. {nexcoder-0.1.3 → nexcoder-0.1.5}/LICENSE +0 -0
  14. {nexcoder-0.1.3 → nexcoder-0.1.5}/README.md +0 -0
  15. {nexcoder-0.1.3 → nexcoder-0.1.5}/src/nex/context.py +0 -0
  16. {nexcoder-0.1.3 → nexcoder-0.1.5}/src/nex/exceptions.py +0 -0
  17. {nexcoder-0.1.3 → nexcoder-0.1.5}/src/nex/indexer/__init__.py +0 -0
  18. {nexcoder-0.1.3 → nexcoder-0.1.5}/src/nex/indexer/index.py +0 -0
  19. {nexcoder-0.1.3 → nexcoder-0.1.5}/src/nex/indexer/parser.py +0 -0
  20. {nexcoder-0.1.3 → nexcoder-0.1.5}/src/nex/indexer/scanner.py +0 -0
  21. {nexcoder-0.1.3 → nexcoder-0.1.5}/src/nex/memory/__init__.py +0 -0
  22. {nexcoder-0.1.3 → nexcoder-0.1.5}/src/nex/memory/decisions.py +0 -0
  23. {nexcoder-0.1.3 → nexcoder-0.1.5}/src/nex/memory/errors.py +0 -0
  24. {nexcoder-0.1.3 → nexcoder-0.1.5}/src/nex/memory/project.py +0 -0
  25. {nexcoder-0.1.3 → nexcoder-0.1.5}/src/nex/py.typed +0 -0
  26. {nexcoder-0.1.3 → nexcoder-0.1.5}/src/nex/reviewer.py +0 -0
  27. {nexcoder-0.1.3 → nexcoder-0.1.5}/src/nex/safety.py +0 -0
  28. {nexcoder-0.1.3 → nexcoder-0.1.5}/src/nex/test_runner.py +0 -0
  29. {nexcoder-0.1.3 → nexcoder-0.1.5}/src/nex/tools/__init__.py +0 -0
  30. {nexcoder-0.1.3 → nexcoder-0.1.5}/src/nex/tools/file_ops.py +0 -0
  31. {nexcoder-0.1.3 → nexcoder-0.1.5}/src/nex/tools/git_ops.py +0 -0
  32. {nexcoder-0.1.3 → nexcoder-0.1.5}/src/nex/tools/search.py +0 -0
  33. {nexcoder-0.1.3 → nexcoder-0.1.5}/src/nex/tools/shell.py +0 -0
  34. {nexcoder-0.1.3 → nexcoder-0.1.5}/tests/conftest.py +0 -0
  35. {nexcoder-0.1.3 → nexcoder-0.1.5}/tests/test_agent.py +0 -0
  36. {nexcoder-0.1.3 → nexcoder-0.1.5}/tests/test_chat.py +0 -0
  37. {nexcoder-0.1.3 → nexcoder-0.1.5}/tests/test_cli.py +0 -0
  38. {nexcoder-0.1.3 → nexcoder-0.1.5}/tests/test_context.py +0 -0
  39. {nexcoder-0.1.3 → nexcoder-0.1.5}/tests/test_errors.py +0 -0
  40. {nexcoder-0.1.3 → nexcoder-0.1.5}/tests/test_indexer.py +0 -0
  41. {nexcoder-0.1.3 → nexcoder-0.1.5}/tests/test_memory.py +0 -0
  42. {nexcoder-0.1.3 → nexcoder-0.1.5}/tests/test_rate_limiter.py +0 -0
  43. {nexcoder-0.1.3 → nexcoder-0.1.5}/tests/test_safety.py +0 -0
  44. {nexcoder-0.1.3 → nexcoder-0.1.5}/tests/test_test_runner.py +0 -0
  45. {nexcoder-0.1.3 → nexcoder-0.1.5}/tests/test_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nexcoder
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: The coding agent that remembers — AI coding assistant with persistent memory and error learning.
5
5
  Project-URL: Homepage, https://github.com/nex-ai/nex-ai
6
6
  Project-URL: Repository, https://github.com/nex-ai/nex-ai
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nexcoder"
7
- version = "0.1.3"
7
+ version = "0.1.5"
8
8
  description = "The coding agent that remembers — AI coding assistant with persistent memory and error learning."
9
9
  readme = {file = "README.md", content-type = "text/markdown"}
10
10
  license = "MIT"
@@ -2,5 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.1.0"
5
+ from importlib.metadata import version
6
+
7
+ __version__ = version("nexcoder")
6
8
  __app_name__ = "nex"
@@ -438,9 +438,10 @@ class Agent:
438
438
  haiku_model = self._nex_config.haiku_model
439
439
 
440
440
  planner = Planner(self._client, haiku_model=haiku_model)
441
+ rate_limiter = RateLimiter(tokens_per_minute=token_rate_limit)
441
442
 
442
443
  console.print("\n[bold]Decomposing task into subtasks...[/bold]")
443
- subtasks = await planner.plan(self._config.task, project_memory)
444
+ subtasks = await planner.plan(self._config.task, project_memory, rate_limiter)
444
445
 
445
446
  console.print(f"\n[bold]Running task:[/bold] {self._config.task}")
446
447
  rate_info = f"Rate limit: {token_rate_limit} tokens/min | " if token_rate_limit else ""
@@ -449,8 +450,6 @@ class Agent:
449
450
  f"{rate_info}"
450
451
  f"Max iterations: {self._config.max_iterations}[/dim]\n"
451
452
  )
452
-
453
- rate_limiter = RateLimiter(tokens_per_minute=token_rate_limit)
454
453
  budget = 20_000
455
454
  if self._nex_config:
456
455
  budget = self._nex_config.subtask_token_budget
@@ -122,6 +122,30 @@ class RateLimiter:
122
122
  return
123
123
 
124
124
 
125
+ def _extract_retry_after(exc: Exception, default: float = 60.0) -> float:
126
+ """Extract retry-after seconds from an Anthropic API error.
127
+
128
+ Inspects the exception's response headers for a ``retry-after`` value.
129
+ Falls back to *default* if the header is missing or unparseable.
130
+
131
+ Args:
132
+ exc: The exception raised by the Anthropic SDK.
133
+ default: Fallback wait time in seconds.
134
+
135
+ Returns:
136
+ Number of seconds to wait before retrying.
137
+ """
138
+ resp = getattr(exc, "response", None)
139
+ if resp is not None:
140
+ header = getattr(resp, "headers", {}).get("retry-after")
141
+ if header:
142
+ try:
143
+ return max(float(header), 1.0)
144
+ except (ValueError, TypeError):
145
+ pass
146
+ return default
147
+
148
+
125
149
  class AnthropicClient:
126
150
  """Async wrapper around the Anthropic API.
127
151
 
@@ -140,7 +164,7 @@ class AnthropicClient:
140
164
  self,
141
165
  api_key: str,
142
166
  default_model: str = "claude-sonnet-4-20250514",
143
- max_retries: int = 3,
167
+ max_retries: int = 5,
144
168
  ) -> None:
145
169
  """Initialize the Anthropic client.
146
170
 
@@ -196,7 +220,9 @@ class AnthropicClient:
196
220
  kwargs["tools"] = tools
197
221
 
198
222
  last_error: Exception | None = None
199
- for attempt in range(self._max_retries + 1):
223
+ # 429 rate limits need more retries with longer waits than server errors
224
+ max_attempts = self._max_retries + 1
225
+ for attempt in range(max_attempts):
200
226
  try:
201
227
  response = await self._client.messages.create(**kwargs)
202
228
 
@@ -230,16 +256,21 @@ class AnthropicClient:
230
256
  except Exception as exc:
231
257
  last_error = exc
232
258
  status_code = getattr(exc, "status_code", None)
259
+ is_rate_limit = status_code == 429
260
+ is_retryable = status_code in (500, 502, 503, 529)
261
+
262
+ if (is_rate_limit or is_retryable) and attempt < max_attempts - 1:
263
+ if is_rate_limit:
264
+ # Rate limits: extract retry-after from response headers,
265
+ # or default to 60s (the full rate-limit window).
266
+ wait = _extract_retry_after(exc, default=60.0)
267
+ else:
268
+ # Server errors: short exponential backoff
269
+ wait = float(2**attempt)
233
270
 
234
- # Retry on rate limit or server errors
235
- if status_code in (429, 500, 502, 503, 529) and attempt < self._max_retries:
236
- wait = 2**attempt
237
- retry_after = getattr(exc, "retry_after", None)
238
- if retry_after:
239
- wait = max(wait, float(retry_after))
240
271
  console.print(
241
- f"[yellow]API error {status_code}, retrying in {wait}s "
242
- f"(attempt {attempt + 1}/{self._max_retries})...[/yellow]"
272
+ f"[yellow]API error {status_code}, retrying in {wait:.0f}s "
273
+ f"(attempt {attempt + 1}/{max_attempts - 1})...[/yellow]"
243
274
  )
244
275
  await asyncio.sleep(wait)
245
276
  continue
@@ -250,7 +281,7 @@ class AnthropicClient:
250
281
  ) from exc
251
282
 
252
283
  raise APIError(
253
- f"Failed after {self._max_retries} retries: {last_error}",
284
+ f"Failed after {max_attempts - 1} retries: {last_error}",
254
285
  )
255
286
 
256
287
  async def close(self) -> None:
@@ -59,7 +59,7 @@ _CONFIG_TEMPLATE = """\
59
59
  # model = "claude-sonnet-4-20250514"
60
60
  # max_iterations = 25
61
61
  # dry_run = false
62
- token_rate_limit = 25000
62
+ token_rate_limit = 20000
63
63
  """
64
64
 
65
65
 
@@ -52,7 +52,7 @@ class NexConfig:
52
52
  nex_dir: Path = field(default_factory=lambda: Path(".nex"))
53
53
  test_command: str = ""
54
54
  test_timeout: int = 120
55
- token_rate_limit: int = 25_000
55
+ token_rate_limit: int = 20_000
56
56
  subtask_token_budget: int = 20_000
57
57
 
58
58
 
@@ -8,7 +8,7 @@ from typing import Any
8
8
 
9
9
  from rich.console import Console
10
10
 
11
- from nex.api_client import AnthropicClient
11
+ from nex.api_client import AnthropicClient, RateLimiter
12
12
 
13
13
  console = Console(stderr=True)
14
14
 
@@ -60,25 +60,36 @@ class Planner:
60
60
  self._client = api_client
61
61
  self._model = haiku_model
62
62
 
63
- async def plan(self, task: str, project_context: str) -> list[Subtask]:
63
+ async def plan(
64
+ self,
65
+ task: str,
66
+ project_context: str,
67
+ rate_limiter: RateLimiter | None = None,
68
+ ) -> list[Subtask]:
64
69
  """Decompose a task into subtasks.
65
70
 
66
71
  Args:
67
72
  task: The user's task description.
68
73
  project_context: Project memory and relevant context.
74
+ rate_limiter: Optional rate limiter to pace the API call.
69
75
 
70
76
  Returns:
71
77
  List of Subtask instances sorted by priority.
72
78
  """
79
+ user_content = f"Project context:\n{project_context}\n\nTask: {task}"
73
80
  messages: list[dict[str, Any]] = [
74
81
  {
75
82
  "role": "user",
76
- "content": f"Project context:\n{project_context}\n\nTask: {task}",
83
+ "content": user_content,
77
84
  }
78
85
  ]
79
86
 
80
87
  console.print("[dim]Planning task decomposition...[/dim]")
81
88
 
89
+ if rate_limiter is not None:
90
+ estimated = len(user_content) // 4
91
+ await rate_limiter.wait_if_needed(estimated)
92
+
82
93
  response = await self._client.send_message(
83
94
  messages=messages,
84
95
  system=_PLANNER_SYSTEM_PROMPT,
@@ -86,6 +97,9 @@ class Planner:
86
97
  max_tokens=2048,
87
98
  )
88
99
 
100
+ if rate_limiter is not None:
101
+ rate_limiter.record(response.input_tokens)
102
+
89
103
  # Extract text response
90
104
  text = ""
91
105
  for block in response.content:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes