regcode 0.6.3__tar.gz → 0.7.0__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.
- {regcode-0.6.3 → regcode-0.7.0}/AGENTS.md +1 -9
- {regcode-0.6.3 → regcode-0.7.0}/PKG-INFO +2 -1
- {regcode-0.6.3 → regcode-0.7.0}/pyproject.toml +2 -1
- {regcode-0.6.3 → regcode-0.7.0}/regcode/config.py +1 -0
- {regcode-0.6.3 → regcode-0.7.0}/regcode/main.py +346 -19
- {regcode-0.6.3 → regcode-0.7.0}/regcode/tools/builtins.py +146 -2
- {regcode-0.6.3 → regcode-0.7.0}/regcode/tui.py +20 -7
- {regcode-0.6.3 → regcode-0.7.0}/tests/test_main.py +12 -9
- {regcode-0.6.3 → regcode-0.7.0}/uv.lock +12 -1
- {regcode-0.6.3 → regcode-0.7.0}/.gitignore +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/.gitlab-ci.yml +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/.python-version +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/.qwen/settings.json +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/GOALS.md +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/README.md +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/config.yaml.template +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/main.py +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/regcode/__init__.py +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/regcode/cli.py +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/regcode/conversation_manager.py +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/regcode/dependencies.py +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/regcode/monty_sandbox.py +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/regcode/permissions.py +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/regcode/sandbox.py +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/regcode/tools/__init__.py +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/regcode/tools/base.py +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/regcode/tools/registry.py +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/regcode/tools/review_notes.py +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/regcode/tools/web_crawl.py +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/regcode/tools/web_search.py +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/regcode/tracing.py +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/tests/__init__.py +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/tests/conftest.py +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/tests/test_api.py +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/tests/test_cli.py +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/tests/test_config.py +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/tests/test_context_manager.py +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/tests/test_dependencies.py +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/tests/test_monty_sandbox.py +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/tests/test_osv_cve.py +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/tests/test_provider_extra.py +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/tests/test_review_notes.py +0 -0
- {regcode-0.6.3 → regcode-0.7.0}/tests/test_web_search.py +0 -0
|
@@ -3,25 +3,17 @@ Always make sure to comply to these rules. Under Project Stack, you will find te
|
|
|
3
3
|
|
|
4
4
|
## Project Stack:
|
|
5
5
|
- `uv`: Dependency management for Python runtime
|
|
6
|
-
- Alembic: Database migrations
|
|
7
|
-
- FastAPI: Backend Logic
|
|
8
|
-
- SQLAlchemy ORM: Python database ORM
|
|
9
|
-
- PostgreSQL/SQLITE: Target databases
|
|
10
6
|
- Pytest: Test suite under `tests`
|
|
11
|
-
- NextJS/React: Frontend Code under `frontend/`
|
|
12
7
|
- GitLab: Code Repository/ CI CD
|
|
13
8
|
- .gitlab-ci.yml: Gitlab Runner Config
|
|
14
9
|
- `ruff`: Linting and Code Quality Control
|
|
15
|
-
- `docker`: Deployment is done via `docker-compose.yml`
|
|
16
10
|
- `pyproject.toml`: Project Setup Config
|
|
17
11
|
|
|
18
12
|
## Coding/Implentation Instructions:
|
|
19
|
-
|
|
20
|
-
- Use `alembic revision -m <revision explanation>` to create new revisions. Making arbitrary strings as versions etc. is PROHIBITED!
|
|
13
|
+
|
|
21
14
|
- ALWAYS run `uv run ruff check --fix` after all changes at the end and fix linting issues
|
|
22
15
|
- ALWAYS run `uv run pytest` to run test suite after all changes and fix issues shown by tests.
|
|
23
16
|
- ALWAYS add test cases if relevant.
|
|
24
|
-
- NEVER use emojis! Use icon packs (for example `lucid-react`) if needed.
|
|
25
17
|
- NEVER commit changes. It is upto user to review your code and commit and push it. Unless user specifically asks for it, commiting code is PROHIBITED!
|
|
26
18
|
|
|
27
19
|
## Project Vision
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: regcode
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Minimalistic coding agent with Python API and CLI
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Requires-Dist: click>=8.0
|
|
7
7
|
Requires-Dist: crawl4ai>=0.4.0
|
|
8
8
|
Requires-Dist: litellm>=1.83.17
|
|
9
9
|
Requires-Dist: mlflow>=3.14.0
|
|
10
|
+
Requires-Dist: pathspec>=1.1.1
|
|
10
11
|
Requires-Dist: pydantic-monty>=0.0.18
|
|
11
12
|
Requires-Dist: pydantic-settings>=2.14.2
|
|
12
13
|
Requires-Dist: pytest>=8.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "regcode"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.7.0"
|
|
4
4
|
description = "Minimalistic coding agent with Python API and CLI"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -15,6 +15,7 @@ dependencies = [
|
|
|
15
15
|
"pydantic-monty>=0.0.18",
|
|
16
16
|
"mlflow>=3.14.0",
|
|
17
17
|
"textual>=2.0.0",
|
|
18
|
+
"pathspec>=1.1.1",
|
|
18
19
|
]
|
|
19
20
|
|
|
20
21
|
[project.scripts]
|
|
@@ -125,6 +125,7 @@ class Config(BaseModel):
|
|
|
125
125
|
tool_budget: int = 20
|
|
126
126
|
permissions: list[AgentPermission] = [AgentPermission.READ, AgentPermission.EXECUTE]
|
|
127
127
|
toolset_config: ToolSetConfig = ToolSetConfig()
|
|
128
|
+
debug_mode: bool = False
|
|
128
129
|
|
|
129
130
|
@classmethod
|
|
130
131
|
def load(cls, path: str | None = None) -> "Config":
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
6
8
|
import warnings
|
|
7
9
|
from datetime import datetime, timezone
|
|
8
10
|
from enum import Enum
|
|
@@ -24,6 +26,9 @@ from regcode.tracing import setup_mlflow_tracing
|
|
|
24
26
|
|
|
25
27
|
setup_mlflow_tracing()
|
|
26
28
|
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
27
32
|
class _SafeEncoder(json.JSONEncoder):
|
|
28
33
|
def default(self, o):
|
|
29
34
|
if hasattr(o, "__repr__"):
|
|
@@ -83,6 +88,10 @@ class Agent:
|
|
|
83
88
|
self._tool_budget_exhausted = False
|
|
84
89
|
self._cumulative_cost: float = 0.0
|
|
85
90
|
self._total_tokens: int = 0
|
|
91
|
+
self._llm_call_log_file: str | None = None
|
|
92
|
+
|
|
93
|
+
if self.config.debug_mode:
|
|
94
|
+
self.enable_llm_call_logging("llm_calls.json")
|
|
86
95
|
|
|
87
96
|
# Context window manager
|
|
88
97
|
self.context_manager = ConversationManager()
|
|
@@ -129,7 +138,9 @@ class Agent:
|
|
|
129
138
|
def _format_tool_descriptions(self) -> str:
|
|
130
139
|
"""Format tool definitions for inclusion in system prompts.
|
|
131
140
|
|
|
132
|
-
Returns a formatted string with each tool's name and description
|
|
141
|
+
Returns a formatted string with each tool's name and description,
|
|
142
|
+
plus explicit restrictions to prevent the model from generating
|
|
143
|
+
tool calls that are guaranteed to fail.
|
|
133
144
|
"""
|
|
134
145
|
tool_defs = self.registry.list_tools()
|
|
135
146
|
if not tool_defs:
|
|
@@ -145,6 +156,37 @@ class Agent:
|
|
|
145
156
|
"You have access to the following tools.\n"
|
|
146
157
|
"Use them when appropriate to gather information\n"
|
|
147
158
|
"or perform tasks:\n"
|
|
159
|
+
"\n"
|
|
160
|
+
"### TOOL RESTRICTIONS (MUST FOLLOW)\n"
|
|
161
|
+
"The sandboxed tools have strict limitations. If a tool\n"
|
|
162
|
+
"execution returns an error, do NOT repeat the same call.\n"
|
|
163
|
+
"Instead, try a different approach or provide a text answer.\n"
|
|
164
|
+
"\n"
|
|
165
|
+
"1. **python_code / run_script**: Run in a sandboxed Python\n"
|
|
166
|
+
" environment. Available stdlib modules are\n"
|
|
167
|
+
" limited — NO subprocess, NO urllib, NO socket, NO\n"
|
|
168
|
+
" network access, NO external packages. Use only\n"
|
|
169
|
+
" built-in modules like os, sys, json, re, pathlib.\n"
|
|
170
|
+
" If it fails once, try a different approach — do not\n"
|
|
171
|
+
" retry the same failing call.\n"
|
|
172
|
+
"\n"
|
|
173
|
+
"2. **shell_command**: Runs in a restricted shell. The\n"
|
|
174
|
+
" following are FORBIDDEN and will be rejected:\n"
|
|
175
|
+
" - Pipes, redirects, or I/O redirection (no |, >,\n"
|
|
176
|
+
" >>, 2>&1, |&, etc.)\n"
|
|
177
|
+
" - The `uv` command is not available\n"
|
|
178
|
+
" - Only whitelisted commands are allowed (ls, cat,\n"
|
|
179
|
+
" echo, grep, head, tail, find, wc, sort, etc.)\n"
|
|
180
|
+
" - If a command is rejected, try a different\n"
|
|
181
|
+
" approach or use another tool.\n"
|
|
182
|
+
"\n"
|
|
183
|
+
"3. **General rule**: If a tool call fails, do NOT\n"
|
|
184
|
+
" repeat the exact same call. The model should adapt\n"
|
|
185
|
+
" to the error and try an alternative. After 3\n"
|
|
186
|
+
" consecutive turns where ALL tool calls failed, the\n"
|
|
187
|
+
" agent loop will force-terminate and fall back to\n"
|
|
188
|
+
" producing a text response. So be smart about\n"
|
|
189
|
+
" switching strategies early.\n"
|
|
148
190
|
)
|
|
149
191
|
return header + "\n".join(lines)
|
|
150
192
|
|
|
@@ -177,6 +219,212 @@ class Agent:
|
|
|
177
219
|
base_kwargs["messages"] = messages
|
|
178
220
|
return base_kwargs
|
|
179
221
|
|
|
222
|
+
def _log_llm_call(
|
|
223
|
+
self,
|
|
224
|
+
messages: list[dict[str, Any]],
|
|
225
|
+
response,
|
|
226
|
+
turn_index: int = 0,
|
|
227
|
+
) -> None:
|
|
228
|
+
"""Log every LLM invocation to a JSON file.
|
|
229
|
+
|
|
230
|
+
Writes one JSON object per call so you can inspect the exact
|
|
231
|
+
message thread and response for each LLM call. Helps diagnose
|
|
232
|
+
issues where the agent says one thing and then stops.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
messages: The messages sent to litellm.completion().
|
|
236
|
+
response: The litellm completion response object.
|
|
237
|
+
turn_index: Which LLM call in the current chat turn (1-based).
|
|
238
|
+
"""
|
|
239
|
+
if not self._llm_call_log_file:
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
log_path = Path(self._llm_call_log_file)
|
|
243
|
+
if not log_path.is_absolute():
|
|
244
|
+
log_path = Path.cwd() / log_path
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
tool_calls = []
|
|
248
|
+
if (
|
|
249
|
+
hasattr(response.choices[0].message, "tool_calls")
|
|
250
|
+
and response.choices[0].message.tool_calls
|
|
251
|
+
):
|
|
252
|
+
for tc in response.choices[0].message.tool_calls:
|
|
253
|
+
if hasattr(tc, "function"):
|
|
254
|
+
tool_calls.append(
|
|
255
|
+
{
|
|
256
|
+
"id": tc.id,
|
|
257
|
+
"function": {
|
|
258
|
+
"name": tc.function.name,
|
|
259
|
+
"arguments": tc.function.arguments,
|
|
260
|
+
},
|
|
261
|
+
}
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
log_entry = {
|
|
265
|
+
"turn_index": turn_index,
|
|
266
|
+
"messages": messages,
|
|
267
|
+
"response": {
|
|
268
|
+
"content": (
|
|
269
|
+
response.choices[0].message.content
|
|
270
|
+
if hasattr(response.choices[0].message, "content")
|
|
271
|
+
and response.choices[0].message.content
|
|
272
|
+
else ""
|
|
273
|
+
),
|
|
274
|
+
"tool_calls": tool_calls,
|
|
275
|
+
"finish_reason": (
|
|
276
|
+
getattr(response.choices[0], "finish_reason", None)
|
|
277
|
+
),
|
|
278
|
+
"usage": (
|
|
279
|
+
{
|
|
280
|
+
"prompt_tokens": getattr(
|
|
281
|
+
response.usage, "prompt_tokens", None
|
|
282
|
+
),
|
|
283
|
+
"completion_tokens": getattr(
|
|
284
|
+
response.usage, "completion_tokens", None
|
|
285
|
+
),
|
|
286
|
+
"total_tokens": getattr(
|
|
287
|
+
response.usage, "total_tokens", None
|
|
288
|
+
),
|
|
289
|
+
}
|
|
290
|
+
if hasattr(response, "usage") and response.usage
|
|
291
|
+
else None
|
|
292
|
+
),
|
|
293
|
+
},
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
with open(log_path, "a") as f:
|
|
297
|
+
f.write(json.dumps(log_entry, cls=_SafeEncoder, indent=2) + "\n")
|
|
298
|
+
|
|
299
|
+
except Exception as exc:
|
|
300
|
+
print(
|
|
301
|
+
f"WARNING: Failed to write LLM call log to {log_path}: {exc}",
|
|
302
|
+
file=sys.stderr,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
def enable_llm_call_logging(self, log_file: str = "llm_calls.json") -> None:
|
|
306
|
+
"""Enable per-call LLM logging to the given file.
|
|
307
|
+
|
|
308
|
+
Call this to start logging every LLM completion call with the
|
|
309
|
+
full message thread and response.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
log_file: Path to the JSON log file.
|
|
313
|
+
"""
|
|
314
|
+
self._llm_call_log_file = log_file
|
|
315
|
+
logger.info("LLM call logging enabled: %s", self._llm_call_log_file)
|
|
316
|
+
|
|
317
|
+
def _maybe_compact(
|
|
318
|
+
self, messages: list[dict[str, Any]]
|
|
319
|
+
) -> list[dict[str, Any]] | None:
|
|
320
|
+
"""Auto-compact context if tool results caused token explosion.
|
|
321
|
+
|
|
322
|
+
Checks the current token count and compacts if we're approaching
|
|
323
|
+
the context window limit. This prevents ContextWindowExceeded errors
|
|
324
|
+
after large tool outputs (e.g., reading large files).
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
messages: Current message list to check.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
The compacted messages list if compaction occurred, or None if no
|
|
331
|
+
compaction was needed.
|
|
332
|
+
"""
|
|
333
|
+
if not self.config.agent.enable_compaction:
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
try:
|
|
337
|
+
# Estimate current token count
|
|
338
|
+
token_count = litellm.token_counter(
|
|
339
|
+
model=self.config.provider.model,
|
|
340
|
+
messages=messages,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
# Check if we need compaction
|
|
344
|
+
if token_count > 0:
|
|
345
|
+
available_space = max(
|
|
346
|
+
1, self._context_window - self.config.provider.max_tokens
|
|
347
|
+
)
|
|
348
|
+
threshold = available_space * self.config.agent.compaction_threshold
|
|
349
|
+
if token_count > threshold:
|
|
350
|
+
logger.info(
|
|
351
|
+
"Auto-compaction triggered: %d tokens (threshold: %d)",
|
|
352
|
+
token_count,
|
|
353
|
+
int(available_space * self.config.agent.compaction_threshold),
|
|
354
|
+
)
|
|
355
|
+
# Notify TUI if active
|
|
356
|
+
if self._status_callback is not None:
|
|
357
|
+
self._status_callback(AgentStatus.COMPACTION_START, None)
|
|
358
|
+
# Compact context manager
|
|
359
|
+
self.context_manager.compact()
|
|
360
|
+
# Also compact the local messages list
|
|
361
|
+
compacted_messages = self._compact_messages(messages)
|
|
362
|
+
logger.info(
|
|
363
|
+
"Compaction complete, new token count: %d",
|
|
364
|
+
litellm.token_counter(
|
|
365
|
+
model=self.config.provider.model,
|
|
366
|
+
messages=compacted_messages,
|
|
367
|
+
),
|
|
368
|
+
)
|
|
369
|
+
return compacted_messages
|
|
370
|
+
except Exception:
|
|
371
|
+
# Never crash on compaction failure
|
|
372
|
+
pass
|
|
373
|
+
|
|
374
|
+
return None
|
|
375
|
+
|
|
376
|
+
def _compact_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
377
|
+
"""Compact a messages list similar to ConversationManager.compact().
|
|
378
|
+
|
|
379
|
+
Keeps the first message (system prompt), the most recent user query,
|
|
380
|
+
and the last N messages. This ensures a valid user query always
|
|
381
|
+
survives compaction so the model never receives "No user query found".
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
messages: The messages to compact.
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
The compacted messages list.
|
|
388
|
+
"""
|
|
389
|
+
if len(messages) <= 2:
|
|
390
|
+
return messages
|
|
391
|
+
|
|
392
|
+
# Keep the first message (system prompt)
|
|
393
|
+
first_msg = messages[0]
|
|
394
|
+
|
|
395
|
+
# Keep only the last N messages — cap aggressively so the compacted
|
|
396
|
+
# list actually fits inside the context window. Tool results are huge;
|
|
397
|
+
# keeping 1/3 of messages with 200+ entries still overflows.
|
|
398
|
+
# Target: last 6 messages (1.5 roundtrips: user/assistant/tool/tool_result).
|
|
399
|
+
total_remaining = len(messages) - 1
|
|
400
|
+
if total_remaining <= 0:
|
|
401
|
+
recent_count = 0
|
|
402
|
+
else:
|
|
403
|
+
recent_count = min(6, total_remaining)
|
|
404
|
+
|
|
405
|
+
if recent_count == 0:
|
|
406
|
+
# Even if we can only keep system prompt, ensure there's a user msg
|
|
407
|
+
recent_msgs = [m for m in messages[1:] if m["role"] == "user"]
|
|
408
|
+
return [first_msg] + recent_msgs
|
|
409
|
+
|
|
410
|
+
recent_msgs = list(messages[-recent_count:])
|
|
411
|
+
|
|
412
|
+
# Ensure there's at least one user message in the compacted list.
|
|
413
|
+
# If the recent window contains no user message, find the most recent
|
|
414
|
+
# user message from the discarded section and include it.
|
|
415
|
+
has_user = any(m["role"] == "user" for m in recent_msgs)
|
|
416
|
+
if not has_user:
|
|
417
|
+
discarded = messages[1:-recent_count]
|
|
418
|
+
# Walk backwards to find the latest user message
|
|
419
|
+
for i in range(len(discarded) - 1, -1, -1):
|
|
420
|
+
if discarded[i]["role"] == "user":
|
|
421
|
+
# Insert just before the recent messages
|
|
422
|
+
recent_msgs.insert(0, discarded[i])
|
|
423
|
+
break
|
|
424
|
+
|
|
425
|
+
# Build compacted list
|
|
426
|
+
return [first_msg] + recent_msgs
|
|
427
|
+
|
|
180
428
|
def chat(
|
|
181
429
|
self,
|
|
182
430
|
message: str | None = None,
|
|
@@ -234,7 +482,14 @@ class Agent:
|
|
|
234
482
|
final_text = ""
|
|
235
483
|
total_tool_calls = 0
|
|
236
484
|
|
|
237
|
-
for
|
|
485
|
+
for iteration in range(max_tool_calls):
|
|
486
|
+
# Compact context BEFORE each request to prevent token overflow.
|
|
487
|
+
# After multiple tool roundtrips the accumulated messages can
|
|
488
|
+
# easily exceed the model's context window.
|
|
489
|
+
maybe_compacted = self._maybe_compact(messages)
|
|
490
|
+
if maybe_compacted is not None:
|
|
491
|
+
messages = maybe_compacted
|
|
492
|
+
|
|
238
493
|
# When budget is exhausted, rebuild system prompt without tools
|
|
239
494
|
# to prevent the model from hallucinating tool calls
|
|
240
495
|
if self._tool_budget_exhausted:
|
|
@@ -259,11 +514,21 @@ class Agent:
|
|
|
259
514
|
|
|
260
515
|
response = litellm.completion(**completion_kwargs)
|
|
261
516
|
|
|
517
|
+
# Log every LLM call with full message thread and response
|
|
518
|
+
self._log_llm_call(messages, response, turn_index=iteration + 1)
|
|
519
|
+
|
|
262
520
|
# Track cost and tokens from this response
|
|
263
521
|
self._track_response_cost(response)
|
|
264
522
|
|
|
265
523
|
choice = response.choices[0] # type: ignore
|
|
266
524
|
message_obj = choice.message # type: ignore
|
|
525
|
+
finish_reason = getattr(choice, "finish_reason", None)
|
|
526
|
+
# Normalize finish_reason to handle Mock objects that auto-create
|
|
527
|
+
# attributes — MagicMock() is not None, which prevents the break
|
|
528
|
+
# logic from triggering on normal responses.
|
|
529
|
+
if finish_reason is not None and not isinstance(finish_reason, str):
|
|
530
|
+
finish_reason = None
|
|
531
|
+
|
|
267
532
|
response_message = {
|
|
268
533
|
"role": "assistant",
|
|
269
534
|
"content": message_obj.content or "",
|
|
@@ -277,13 +542,30 @@ class Agent:
|
|
|
277
542
|
if message_obj.content:
|
|
278
543
|
final_text = message_obj.content
|
|
279
544
|
|
|
280
|
-
#
|
|
281
|
-
|
|
545
|
+
# Route based on finish_reason, not just tool_calls presence
|
|
546
|
+
if finish_reason == "stop":
|
|
547
|
+
# Model explicitly done — break and return the accumulated text
|
|
548
|
+
break
|
|
549
|
+
|
|
550
|
+
# Fallback: if finish_reason is None (legacy/mocked) but we have
|
|
551
|
+
# content with no tool_calls, the model is done
|
|
552
|
+
if (
|
|
553
|
+
finish_reason is None
|
|
554
|
+
and message_obj.content
|
|
555
|
+
and (not message_obj.tool_calls or len(message_obj.tool_calls) == 0)
|
|
556
|
+
):
|
|
557
|
+
break
|
|
558
|
+
|
|
282
559
|
if self._tool_budget_exhausted:
|
|
560
|
+
# Budget exhausted: the LLM was called without tools and
|
|
561
|
+
# returned text. Break and return it.
|
|
283
562
|
break
|
|
284
563
|
|
|
285
|
-
#
|
|
286
|
-
|
|
564
|
+
# Tool execution: either model explicitly said "tool_calls" or
|
|
565
|
+
# finish_reason is None (legacy/mocked responses) but tool_calls exist
|
|
566
|
+
if message_obj.tool_calls:
|
|
567
|
+
assert message_obj.tool_calls is not None
|
|
568
|
+
# Model requested tool execution — execute them and continue
|
|
287
569
|
for tool_call in message_obj.tool_calls:
|
|
288
570
|
# Validate tool call before processing - skip malformed calls
|
|
289
571
|
if (not tool_call.id or
|
|
@@ -413,9 +695,27 @@ class Agent:
|
|
|
413
695
|
)
|
|
414
696
|
total_tool_calls += 1
|
|
415
697
|
self._update_context_progress(messages)
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
698
|
+
|
|
699
|
+
continue
|
|
700
|
+
|
|
701
|
+
# Recovery: model said "tool_calls" but provided none
|
|
702
|
+
# This happens when the model scrambled its last tool call.
|
|
703
|
+
# Only `stop` terminates the loop; for malformed tool_calls
|
|
704
|
+
# we keep re-prompting until the model complies or stops.
|
|
705
|
+
if finish_reason == "tool_calls" and (
|
|
706
|
+
not message_obj.tool_calls or len(message_obj.tool_calls) == 0
|
|
707
|
+
):
|
|
708
|
+
messages.append({
|
|
709
|
+
"role": "user",
|
|
710
|
+
"content": (
|
|
711
|
+
"You indicated tool calls but provided none. "
|
|
712
|
+
"Your response was malformed. Please provide a valid "
|
|
713
|
+
"text answer or a properly formatted tool call. "
|
|
714
|
+
"Do not use any special formatting, tags, or "
|
|
715
|
+
"code markers. Just the answer."
|
|
716
|
+
),
|
|
717
|
+
})
|
|
718
|
+
total_tool_calls += 1
|
|
419
719
|
continue
|
|
420
720
|
|
|
421
721
|
# Model returned neither text content nor tool calls — re-prompt it
|
|
@@ -423,9 +723,6 @@ class Agent:
|
|
|
423
723
|
if not message_obj.content and (
|
|
424
724
|
not message_obj.tool_calls or len(message_obj.tool_calls) == 0
|
|
425
725
|
):
|
|
426
|
-
if total_tool_calls >= 5:
|
|
427
|
-
# After 5 empty responses, just give up and return empty
|
|
428
|
-
break
|
|
429
726
|
messages.append(
|
|
430
727
|
{
|
|
431
728
|
"role": "user",
|
|
@@ -439,7 +736,9 @@ class Agent:
|
|
|
439
736
|
total_tool_calls += 1
|
|
440
737
|
continue
|
|
441
738
|
|
|
442
|
-
|
|
739
|
+
# Only exit the loop on budget exhaustion or finish_reason == "stop".
|
|
740
|
+
# All other paths re-prompt or continue to allow the model to adapt.
|
|
741
|
+
# We do not break here on non-stop finish_reasons.
|
|
443
742
|
|
|
444
743
|
self._update_context_progress(messages)
|
|
445
744
|
self._history = [m for m in messages if m.get("role") != "system"]
|
|
@@ -547,7 +846,12 @@ class Agent:
|
|
|
547
846
|
response_message: dict[str, Any] = {}
|
|
548
847
|
total_tool_calls = 0
|
|
549
848
|
|
|
550
|
-
for
|
|
849
|
+
for iteration in range(max_tool_calls):
|
|
850
|
+
# Compact context BEFORE each request to prevent token overflow.
|
|
851
|
+
maybe_compacted = self._maybe_compact(messages)
|
|
852
|
+
if maybe_compacted is not None:
|
|
853
|
+
messages = maybe_compacted
|
|
854
|
+
|
|
551
855
|
system_prompt_text = self._build_system_prompt_no_tools()
|
|
552
856
|
messages = [
|
|
553
857
|
{"role": "system", "content": system_prompt_text},
|
|
@@ -571,12 +875,16 @@ class Agent:
|
|
|
571
875
|
|
|
572
876
|
response = litellm.completion(**completion_kwargs)
|
|
573
877
|
|
|
878
|
+
# Log every LLM call with full message thread and response
|
|
879
|
+
self._log_llm_call(messages, response, turn_index=iteration + 1)
|
|
880
|
+
|
|
574
881
|
# Track cost and tokens from this response
|
|
575
882
|
self._track_response_cost(response)
|
|
576
883
|
|
|
577
884
|
# Stream the response — accumulate text and tool_calls
|
|
578
885
|
# across ALL chunks, not just the last one.
|
|
579
886
|
full_text = ""
|
|
887
|
+
stream_finish_reason: str | None = None
|
|
580
888
|
# Accumulate delta tool calls by index so we can merge partial
|
|
581
889
|
# chunks (id in first chunk, function name in second, arguments
|
|
582
890
|
# accumulating in subsequent chunks) into a single per-call object.
|
|
@@ -584,6 +892,9 @@ class Agent:
|
|
|
584
892
|
try:
|
|
585
893
|
for chunk in response:
|
|
586
894
|
delta = chunk.choices[0].delta # type: ignore
|
|
895
|
+
# Capture finish_reason from the last chunk
|
|
896
|
+
if delta.finish_reason is not None:
|
|
897
|
+
stream_finish_reason = delta.finish_reason
|
|
587
898
|
# Accumulate text content
|
|
588
899
|
if delta.content:
|
|
589
900
|
full_text += delta.content
|
|
@@ -648,6 +959,25 @@ class Agent:
|
|
|
648
959
|
if self._tool_budget_exhausted:
|
|
649
960
|
break
|
|
650
961
|
|
|
962
|
+
# Route based on finish_reason, not just tool_calls presence
|
|
963
|
+
if stream_finish_reason == "stop":
|
|
964
|
+
# Model is done — break and return the accumulated text
|
|
965
|
+
break
|
|
966
|
+
|
|
967
|
+
# Recovery: model said "tool_calls" but provided none
|
|
968
|
+
# This happens when the model scrambled its last tool call
|
|
969
|
+
if stream_finish_reason == "tool_calls" and not valid_tool_calls:
|
|
970
|
+
messages.append({
|
|
971
|
+
"role": "user",
|
|
972
|
+
"content": (
|
|
973
|
+
"You indicated tool calls but provided none. "
|
|
974
|
+
"Your response was malformed. Please provide a valid "
|
|
975
|
+
"text answer or a properly formatted tool call."
|
|
976
|
+
),
|
|
977
|
+
})
|
|
978
|
+
total_tool_calls += 1
|
|
979
|
+
continue
|
|
980
|
+
|
|
651
981
|
if valid_tool_calls:
|
|
652
982
|
for tc_dict in valid_tool_calls:
|
|
653
983
|
tool_name = str(tc_dict["function"]["name"])
|
|
@@ -787,8 +1117,7 @@ class Agent:
|
|
|
787
1117
|
)
|
|
788
1118
|
total_tool_calls += 1
|
|
789
1119
|
self._update_context_progress(messages)
|
|
790
|
-
|
|
791
|
-
break
|
|
1120
|
+
|
|
792
1121
|
continue
|
|
793
1122
|
|
|
794
1123
|
# Model returned neither text content nor tool calls — re-prompt it
|
|
@@ -797,9 +1126,6 @@ class Agent:
|
|
|
797
1126
|
not response_message.get("tool_calls") or
|
|
798
1127
|
len(response_message.get("tool_calls", [])) == 0
|
|
799
1128
|
):
|
|
800
|
-
if total_tool_calls >= 5:
|
|
801
|
-
# After 5 empty responses, just give up and return empty
|
|
802
|
-
break
|
|
803
1129
|
messages.append(
|
|
804
1130
|
{
|
|
805
1131
|
"role": "user",
|
|
@@ -1063,3 +1389,4 @@ class Agent:
|
|
|
1063
1389
|
def __exit__(self, *args: Any) -> None:
|
|
1064
1390
|
"""Exit context manager."""
|
|
1065
1391
|
self.cleanup()
|
|
1392
|
+
|
|
@@ -15,6 +15,9 @@ import urllib.request
|
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
from typing import Any
|
|
17
17
|
|
|
18
|
+
from pathspec import PathSpec
|
|
19
|
+
from pathspec.patterns import GitWildMatchPattern
|
|
20
|
+
|
|
18
21
|
from regcode.monty_sandbox import MontySandbox
|
|
19
22
|
from regcode.tools.base import BaseTool, ToolParam, ToolResult
|
|
20
23
|
from regcode.tools.web_crawl import WebCrawlerTool
|
|
@@ -271,11 +274,92 @@ class ShellCommandTool(BaseTool):
|
|
|
271
274
|
|
|
272
275
|
|
|
273
276
|
class ReadFileTool(BaseTool):
|
|
274
|
-
"""Read files from the sandbox filesystem.
|
|
277
|
+
"""Read files from the sandbox filesystem.
|
|
278
|
+
|
|
279
|
+
Blocklist prevents reading large token-heavy files that waste context
|
|
280
|
+
and provide no value (lock files, node_modules, binary artifacts, etc).
|
|
281
|
+
Also respects .gitignore patterns from the repository root.
|
|
282
|
+
"""
|
|
275
283
|
|
|
276
284
|
name = "read_file"
|
|
277
285
|
description = "Read the contents of a file from the sandbox filesystem."
|
|
278
286
|
|
|
287
|
+
# Files and patterns that are too large/toxic for context — never read them
|
|
288
|
+
BLOCKED_NAMES: frozenset[str] = frozenset((
|
|
289
|
+
# Package lock files
|
|
290
|
+
"uv.lock",
|
|
291
|
+
"package-lock.json",
|
|
292
|
+
"yarn.lock",
|
|
293
|
+
"pnpm-lock.yaml",
|
|
294
|
+
"Cargo.lock",
|
|
295
|
+
"Gemfile.lock",
|
|
296
|
+
"podfile.lock",
|
|
297
|
+
"composer.lock",
|
|
298
|
+
"go.sum",
|
|
299
|
+
"pubspec.lock",
|
|
300
|
+
"mix.lock",
|
|
301
|
+
# Node modules (traverse blocks)
|
|
302
|
+
"node_modules",
|
|
303
|
+
"__pycache__",
|
|
304
|
+
".git",
|
|
305
|
+
".venv",
|
|
306
|
+
"venv",
|
|
307
|
+
# Binary / generated artifacts
|
|
308
|
+
".DS_Store",
|
|
309
|
+
"Thumbs.db",
|
|
310
|
+
".env",
|
|
311
|
+
".env.local",
|
|
312
|
+
".env.production",
|
|
313
|
+
# Large generated files
|
|
314
|
+
"coverage.json",
|
|
315
|
+
"coverage.xml",
|
|
316
|
+
))
|
|
317
|
+
|
|
318
|
+
BLOCKED_EXTENSIONS: frozenset[str] = frozenset((
|
|
319
|
+
# Binary formats
|
|
320
|
+
".pyc", ".pyo", ".pyd", ".so", ".dll", ".dylib", ".o", ".obj",
|
|
321
|
+
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", ".svg",
|
|
322
|
+
".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar",
|
|
323
|
+
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
|
|
324
|
+
".mp3", ".mp4", ".avi", ".mov", ".wav", ".ogg",
|
|
325
|
+
".woff", ".woff2", ".ttf", ".eot",
|
|
326
|
+
))
|
|
327
|
+
|
|
328
|
+
# Cached .gitignore specs, keyed by repo root
|
|
329
|
+
_gitignore_specs: dict[str, PathSpec] = {}
|
|
330
|
+
|
|
331
|
+
@classmethod
|
|
332
|
+
def _load_gitignore_spec(cls, repo_root: Path) -> PathSpec | None:
|
|
333
|
+
"""Load and parse .gitignore from repo_root if it exists.
|
|
334
|
+
|
|
335
|
+
Returns a PathSpec object, or None if no .gitignore found.
|
|
336
|
+
"""
|
|
337
|
+
cache_key = str(repo_root.resolve())
|
|
338
|
+
if cache_key in cls._gitignore_specs:
|
|
339
|
+
return cls._gitignore_specs[cache_key]
|
|
340
|
+
|
|
341
|
+
gitignore_path = repo_root / ".gitignore"
|
|
342
|
+
if not gitignore_path.exists():
|
|
343
|
+
return None
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
lines = gitignore_path.read_text().splitlines()
|
|
347
|
+
patterns = []
|
|
348
|
+
for line in lines:
|
|
349
|
+
line = line.strip()
|
|
350
|
+
# Skip empty lines and comments
|
|
351
|
+
if not line or line.startswith("#"):
|
|
352
|
+
continue
|
|
353
|
+
patterns.append(GitWildMatchPattern(line))
|
|
354
|
+
if patterns:
|
|
355
|
+
spec = PathSpec.from_lines(GitWildMatchPattern, patterns)
|
|
356
|
+
cls._gitignore_specs[cache_key] = spec
|
|
357
|
+
return spec
|
|
358
|
+
except Exception:
|
|
359
|
+
# Never crash on .gitignore parse failure
|
|
360
|
+
pass
|
|
361
|
+
return None
|
|
362
|
+
|
|
279
363
|
@property
|
|
280
364
|
def params(self) -> list[ToolParam]:
|
|
281
365
|
return [
|
|
@@ -289,14 +373,61 @@ class ReadFileTool(BaseTool):
|
|
|
289
373
|
output="Missing required parameter: path",
|
|
290
374
|
error=True,
|
|
291
375
|
)
|
|
292
|
-
#
|
|
376
|
+
# Check for blocked paths
|
|
293
377
|
p = Path(path)
|
|
378
|
+
# Walk all parts of the path — if any matches a blocked name, reject
|
|
379
|
+
for part in p.parts:
|
|
380
|
+
if part in self.BLOCKED_NAMES:
|
|
381
|
+
return ToolResult(
|
|
382
|
+
output=f"Access denied: '{path}' is a blocked path ({part}). "
|
|
383
|
+
"Reading lock files, node_modules, binary artifacts, and "
|
|
384
|
+
"generated data is not permitted — they waste context "
|
|
385
|
+
"tokens and provide no value.",
|
|
386
|
+
error=True,
|
|
387
|
+
exit_code=1,
|
|
388
|
+
)
|
|
389
|
+
# Check extension
|
|
390
|
+
if p.suffix and p.suffix.lower() in self.BLOCKED_EXTENSIONS:
|
|
391
|
+
return ToolResult(
|
|
392
|
+
output=(
|
|
393
|
+
f"Access denied: '{path}' has blocked extension "
|
|
394
|
+
f"'{p.suffix}'. "
|
|
395
|
+
"Reading binary/artifact files is not permitted."
|
|
396
|
+
),
|
|
397
|
+
error=True,
|
|
398
|
+
exit_code=1,
|
|
399
|
+
)
|
|
294
400
|
if not p.exists():
|
|
295
401
|
return ToolResult(
|
|
296
402
|
output=f"File not found: {path}",
|
|
297
403
|
error=True,
|
|
298
404
|
exit_code=1,
|
|
299
405
|
)
|
|
406
|
+
|
|
407
|
+
# Check against .gitignore patterns
|
|
408
|
+
# Find the git repository root (or project root)
|
|
409
|
+
repo_root = self._find_git_root(p)
|
|
410
|
+
if repo_root is not None:
|
|
411
|
+
spec = self._load_gitignore_spec(repo_root)
|
|
412
|
+
if spec is not None:
|
|
413
|
+
# Get relative path from repo_root
|
|
414
|
+
try:
|
|
415
|
+
rel_path = p.resolve().relative_to(repo_root.resolve())
|
|
416
|
+
# pathspec expects forward slashes
|
|
417
|
+
rel_path_str = str(rel_path).replace("\\", "/")
|
|
418
|
+
if spec.match_file(rel_path_str):
|
|
419
|
+
return ToolResult(
|
|
420
|
+
output=(
|
|
421
|
+
f"Access denied: '{path}' matches a .gitignore "
|
|
422
|
+
"pattern in the repository."
|
|
423
|
+
),
|
|
424
|
+
error=True,
|
|
425
|
+
exit_code=1,
|
|
426
|
+
)
|
|
427
|
+
except ValueError:
|
|
428
|
+
# File is outside repo root, skip gitignore check
|
|
429
|
+
pass
|
|
430
|
+
|
|
300
431
|
try:
|
|
301
432
|
content = p.read_text()
|
|
302
433
|
return ToolResult(output=f"File: {path}\n{content}", exit_code=0)
|
|
@@ -307,6 +438,19 @@ class ReadFileTool(BaseTool):
|
|
|
307
438
|
exit_code=1,
|
|
308
439
|
)
|
|
309
440
|
|
|
441
|
+
@classmethod
|
|
442
|
+
def _find_git_root(cls, file_path: Path) -> Path | None:
|
|
443
|
+
"""Find the git repository root containing the given file.
|
|
444
|
+
|
|
445
|
+
Walks up the directory tree looking for .git directory.
|
|
446
|
+
Returns the repository root, or None if not found.
|
|
447
|
+
"""
|
|
448
|
+
current = file_path.resolve()
|
|
449
|
+
for parent in current.parents:
|
|
450
|
+
if (parent / ".git").exists():
|
|
451
|
+
return parent
|
|
452
|
+
return None
|
|
453
|
+
|
|
310
454
|
|
|
311
455
|
class FetchGitDiffTool(BaseTool):
|
|
312
456
|
"""Fetch the git diff of the current repository."""
|
|
@@ -374,6 +374,21 @@ class ChatApp(App):
|
|
|
374
374
|
self._handle_reset_session()
|
|
375
375
|
return
|
|
376
376
|
|
|
377
|
+
if message == "/dump":
|
|
378
|
+
if self._agent is not None:
|
|
379
|
+
try:
|
|
380
|
+
dump_file = "agent_history.json"
|
|
381
|
+
self._agent.dump_history(dump_file)
|
|
382
|
+
self._append_rich(
|
|
383
|
+
f"[dim]Agent history dumped to {dump_file}[/]\n"
|
|
384
|
+
)
|
|
385
|
+
self._append_rich("─" * 60 + "\n")
|
|
386
|
+
except Exception as e:
|
|
387
|
+
_logger.exception("Error dumping history: %s", e)
|
|
388
|
+
self._append_rich(f"[red]Error: {e}[/]\n")
|
|
389
|
+
self._append_rich("─" * 60 + "\n")
|
|
390
|
+
return
|
|
391
|
+
|
|
377
392
|
if self._agent_running:
|
|
378
393
|
self._msg_queue.append(message)
|
|
379
394
|
return
|
|
@@ -434,15 +449,13 @@ class ChatApp(App):
|
|
|
434
449
|
|
|
435
450
|
result = self._agent.chat(
|
|
436
451
|
message,
|
|
437
|
-
stream=
|
|
438
|
-
on_text_chunk=self.on_text_chunk,
|
|
452
|
+
stream=False,
|
|
439
453
|
)
|
|
440
454
|
|
|
441
|
-
#
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
)
|
|
455
|
+
# Non-streaming: result is the full response.
|
|
456
|
+
# Put it in _current_response so _finish_message renders it.
|
|
457
|
+
if result:
|
|
458
|
+
self._current_response.append(result)
|
|
446
459
|
except Exception as e:
|
|
447
460
|
_logger.exception("Agent error: %s", e)
|
|
448
461
|
self.call_from_thread(
|
|
@@ -959,8 +959,8 @@ def test_chat_reprompt_loop_single_empty():
|
|
|
959
959
|
assert re_prompt_found, "Re-prompt message not found in second call"
|
|
960
960
|
|
|
961
961
|
|
|
962
|
-
def
|
|
963
|
-
"""Test that
|
|
962
|
+
def test_chat_reprompt_loop_empty_until_max_tool_calls():
|
|
963
|
+
"""Test that chat() re-prompts on empty responses until max_tool_calls is hit."""
|
|
964
964
|
from unittest.mock import MagicMock, patch
|
|
965
965
|
|
|
966
966
|
from regcode.config import Config
|
|
@@ -969,7 +969,7 @@ def test_chat_reprompt_loop_five_empty_then_give_up():
|
|
|
969
969
|
agent = Agent(config=config)
|
|
970
970
|
|
|
971
971
|
with patch("regcode.main.litellm.completion") as mock_completion:
|
|
972
|
-
#
|
|
972
|
+
# Empty responses in a row — loop is bounded by max_tool_calls=10
|
|
973
973
|
empty_response = MagicMock(
|
|
974
974
|
choices=[
|
|
975
975
|
MagicMock(
|
|
@@ -984,8 +984,9 @@ def test_chat_reprompt_loop_five_empty_then_give_up():
|
|
|
984
984
|
|
|
985
985
|
result = agent.chat("Hello")
|
|
986
986
|
assert result == ""
|
|
987
|
-
#
|
|
988
|
-
|
|
987
|
+
# Called 10 times (max_tool_calls default) — loop bounded by max_tool_calls,
|
|
988
|
+
# not by an arbitrary empty-response limit
|
|
989
|
+
assert mock_completion.call_count == 10
|
|
989
990
|
|
|
990
991
|
|
|
991
992
|
def test_chat_no_reprompt_when_has_content():
|
|
@@ -1105,8 +1106,8 @@ def test_stream_reprompt_loop_single_empty():
|
|
|
1105
1106
|
assert re_prompt_found
|
|
1106
1107
|
|
|
1107
1108
|
|
|
1108
|
-
def
|
|
1109
|
-
"""Test that
|
|
1109
|
+
def test_stream_reprompt_empty_until_max_tool_calls():
|
|
1110
|
+
"""Test that _stream() re-prompts on empty responses until max_tool_calls is hit."""
|
|
1110
1111
|
from unittest.mock import MagicMock, patch
|
|
1111
1112
|
|
|
1112
1113
|
from regcode.config import Config
|
|
@@ -1115,6 +1116,7 @@ def test_stream_reprompt_five_empty_then_give_up():
|
|
|
1115
1116
|
agent = Agent(config=config)
|
|
1116
1117
|
|
|
1117
1118
|
with patch("regcode.main.litellm.completion") as mock_completion:
|
|
1119
|
+
# Empty responses in a row — loop is bounded by max_tool_calls=10
|
|
1118
1120
|
empty_response = [
|
|
1119
1121
|
MagicMock(choices=[MagicMock(delta=MagicMock(
|
|
1120
1122
|
content=None, tool_calls=None,
|
|
@@ -1126,5 +1128,6 @@ def test_stream_reprompt_five_empty_then_give_up():
|
|
|
1126
1128
|
[{"role": "user", "content": "Hello"}]
|
|
1127
1129
|
)
|
|
1128
1130
|
assert result == ""
|
|
1129
|
-
# Called
|
|
1130
|
-
|
|
1131
|
+
# Called 10 times (max_tool_calls default) — loop bounded by max_tool_calls,
|
|
1132
|
+
# not by an arbitrary empty-response limit
|
|
1133
|
+
assert mock_completion.call_count == 10
|
|
@@ -2961,6 +2961,15 @@ wheels = [
|
|
|
2961
2961
|
{ url = "https://files.pythonhosted.org/packages/c9/b0/2e83f9245e08db9f5d1ed10e5be581feac18ed3b5eadd5dbe32aa487567b/patchright-1.61.1-py3-none-win_arm64.whl", hash = "sha256:be79b2931a76a93e77b2e75970be5e4a65ed856de1f6cd170386fbc78f7f55a8", size = 33932055, upload-time = "2026-06-29T10:37:47.083Z" },
|
|
2962
2962
|
]
|
|
2963
2963
|
|
|
2964
|
+
[[package]]
|
|
2965
|
+
name = "pathspec"
|
|
2966
|
+
version = "1.1.1"
|
|
2967
|
+
source = { registry = "https://pypi.org/simple" }
|
|
2968
|
+
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
|
|
2969
|
+
wheels = [
|
|
2970
|
+
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
|
|
2971
|
+
]
|
|
2972
|
+
|
|
2964
2973
|
[[package]]
|
|
2965
2974
|
name = "pillow"
|
|
2966
2975
|
version = "12.2.0"
|
|
@@ -3805,13 +3814,14 @@ wheels = [
|
|
|
3805
3814
|
|
|
3806
3815
|
[[package]]
|
|
3807
3816
|
name = "regcode"
|
|
3808
|
-
version = "0.
|
|
3817
|
+
version = "0.7.0"
|
|
3809
3818
|
source = { editable = "." }
|
|
3810
3819
|
dependencies = [
|
|
3811
3820
|
{ name = "click" },
|
|
3812
3821
|
{ name = "crawl4ai" },
|
|
3813
3822
|
{ name = "litellm" },
|
|
3814
3823
|
{ name = "mlflow" },
|
|
3824
|
+
{ name = "pathspec" },
|
|
3815
3825
|
{ name = "pydantic-monty" },
|
|
3816
3826
|
{ name = "pydantic-settings" },
|
|
3817
3827
|
{ name = "pytest" },
|
|
@@ -3826,6 +3836,7 @@ requires-dist = [
|
|
|
3826
3836
|
{ name = "crawl4ai", specifier = ">=0.4.0" },
|
|
3827
3837
|
{ name = "litellm", specifier = ">=1.83.17" },
|
|
3828
3838
|
{ name = "mlflow", specifier = ">=3.14.0" },
|
|
3839
|
+
{ name = "pathspec", specifier = ">=1.1.1" },
|
|
3829
3840
|
{ name = "pydantic-monty", specifier = ">=0.0.18" },
|
|
3830
3841
|
{ name = "pydantic-settings", specifier = ">=2.14.2" },
|
|
3831
3842
|
{ name = "pytest", specifier = ">=8.0" },
|
|
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
|
|
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
|