regcode 0.12.0__tar.gz → 0.12.1__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 (54) hide show
  1. {regcode-0.12.0 → regcode-0.12.1}/GOALS.md +51 -1
  2. {regcode-0.12.0 → regcode-0.12.1}/PKG-INFO +1 -1
  3. {regcode-0.12.0 → regcode-0.12.1}/pyproject.toml +1 -1
  4. {regcode-0.12.0 → regcode-0.12.1}/regcode/__init__.py +1 -1
  5. {regcode-0.12.0 → regcode-0.12.1}/regcode/agents_backend.py +86 -14
  6. regcode-0.12.1/regcode/async_utils.py +58 -0
  7. {regcode-0.12.0 → regcode-0.12.1}/regcode/main.py +54 -18
  8. {regcode-0.12.0 → regcode-0.12.1}/regcode/monty_sandbox.py +13 -11
  9. {regcode-0.12.0 → regcode-0.12.1}/regcode/tools/web_crawl.py +6 -3
  10. {regcode-0.12.0 → regcode-0.12.1}/tests/test_clipboard.py +9 -1
  11. {regcode-0.12.0 → regcode-0.12.1}/tests/test_main.py +8 -1
  12. {regcode-0.12.0 → regcode-0.12.1}/tests/test_task_regression.py +292 -0
  13. {regcode-0.12.0 → regcode-0.12.1}/uv.lock +36 -36
  14. {regcode-0.12.0 → regcode-0.12.1}/.gitignore +0 -0
  15. {regcode-0.12.0 → regcode-0.12.1}/.gitlab-ci.yml +0 -0
  16. {regcode-0.12.0 → regcode-0.12.1}/.python-version +0 -0
  17. {regcode-0.12.0 → regcode-0.12.1}/.qwen/settings.json +0 -0
  18. {regcode-0.12.0 → regcode-0.12.1}/AGENTS.md +0 -0
  19. {regcode-0.12.0 → regcode-0.12.1}/README.md +0 -0
  20. {regcode-0.12.0 → regcode-0.12.1}/config.yaml.template +0 -0
  21. {regcode-0.12.0 → regcode-0.12.1}/main.py +0 -0
  22. {regcode-0.12.0 → regcode-0.12.1}/regcode/cli.py +0 -0
  23. {regcode-0.12.0 → regcode-0.12.1}/regcode/clipboard.py +0 -0
  24. {regcode-0.12.0 → regcode-0.12.1}/regcode/config.py +0 -0
  25. {regcode-0.12.0 → regcode-0.12.1}/regcode/conversation_manager.py +0 -0
  26. {regcode-0.12.0 → regcode-0.12.1}/regcode/dependencies.py +0 -0
  27. {regcode-0.12.0 → regcode-0.12.1}/regcode/mcp_client.py +0 -0
  28. {regcode-0.12.0 → regcode-0.12.1}/regcode/paste.py +0 -0
  29. {regcode-0.12.0 → regcode-0.12.1}/regcode/permissions.py +0 -0
  30. {regcode-0.12.0 → regcode-0.12.1}/regcode/sandbox.py +0 -0
  31. {regcode-0.12.0 → regcode-0.12.1}/regcode/tools/__init__.py +0 -0
  32. {regcode-0.12.0 → regcode-0.12.1}/regcode/tools/base.py +0 -0
  33. {regcode-0.12.0 → regcode-0.12.1}/regcode/tools/builtins.py +0 -0
  34. {regcode-0.12.0 → regcode-0.12.1}/regcode/tools/registry.py +0 -0
  35. {regcode-0.12.0 → regcode-0.12.1}/regcode/tools/review_notes.py +0 -0
  36. {regcode-0.12.0 → regcode-0.12.1}/regcode/tools/web_search.py +0 -0
  37. {regcode-0.12.0 → regcode-0.12.1}/regcode/tracing.py +0 -0
  38. {regcode-0.12.0 → regcode-0.12.1}/regcode/tui.py +0 -0
  39. {regcode-0.12.0 → regcode-0.12.1}/tasks.md +0 -0
  40. {regcode-0.12.0 → regcode-0.12.1}/tests/__init__.py +0 -0
  41. {regcode-0.12.0 → regcode-0.12.1}/tests/conftest.py +0 -0
  42. {regcode-0.12.0 → regcode-0.12.1}/tests/llm_fakes.py +0 -0
  43. {regcode-0.12.0 → regcode-0.12.1}/tests/test_api.py +0 -0
  44. {regcode-0.12.0 → regcode-0.12.1}/tests/test_cli.py +0 -0
  45. {regcode-0.12.0 → regcode-0.12.1}/tests/test_config.py +0 -0
  46. {regcode-0.12.0 → regcode-0.12.1}/tests/test_context_manager.py +0 -0
  47. {regcode-0.12.0 → regcode-0.12.1}/tests/test_dependencies.py +0 -0
  48. {regcode-0.12.0 → regcode-0.12.1}/tests/test_mcp.py +0 -0
  49. {regcode-0.12.0 → regcode-0.12.1}/tests/test_mcp_status.py +0 -0
  50. {regcode-0.12.0 → regcode-0.12.1}/tests/test_monty_sandbox.py +0 -0
  51. {regcode-0.12.0 → regcode-0.12.1}/tests/test_osv_cve.py +0 -0
  52. {regcode-0.12.0 → regcode-0.12.1}/tests/test_provider_extra.py +0 -0
  53. {regcode-0.12.0 → regcode-0.12.1}/tests/test_review_notes.py +0 -0
  54. {regcode-0.12.0 → regcode-0.12.1}/tests/test_web_search.py +0 -0
@@ -643,5 +643,55 @@ for the per-task mapping to tests.
643
643
  restoring it, so later tests received a second `AgentStatus` class and enum identity
644
644
  comparisons failed purely on execution order.
645
645
 
646
- 11. ✅ **270 tests pass** (up from 231), `uv run ruff check` clean.
646
+ 11. ✅ **285 tests pass** (up from 231), `uv run ruff check` clean.
647
+
648
+ ## Fixed (2026-10-31 — CLI broke on the first real conversation)
649
+
650
+ The rewrite passed every test but broke `regcode chat` as soon as the model said
651
+ anything before calling a tool:
652
+
653
+ ```
654
+ [ERROR] Agent error! litellm.BadRequestError: Hosted_vllmException - {"error":{"message":
655
+ "3 validation errors for ValidatorIterator\n0.ChatCompletionContentPartTextParam.type\n
656
+ Input should be 'text' [type=literal_error, input_value='output_text'...
657
+ ```
658
+
659
+ Three separate regressions, all invisible to the existing suite because **every test
660
+ made the model return *either* text *or* a tool call** — so the second turn, the one
661
+ where the Runner replays the assistant's own output, never executed.
662
+
663
+ 1. ✅ **Responses-API items were leaking into chat-completions requests** — the Runner
664
+ hands the assistant's output back as `{"type": "message", "content": [{"type":
665
+ "output_text", ...}]}`, `function_call` and `function_call_output`. `_prepare_messages`
666
+ copied those dicts straight into `litellm.completion(messages=...)`, which the provider
667
+ rejects. It now runs them through `Converter.items_to_messages(items, model=...)`, the
668
+ same translation the SDK's own `LitellmModel` uses — which also merges an assistant's
669
+ text with the tool calls it asked for into a single turn.
670
+
671
+ 2. ✅ **Replayed history was structurally invalid** — `_update_history_from_sdk_result`
672
+ stored a `"Tool call: x"` label next to a `role: "tool"` result. A tool result is only
673
+ legal when an assistant turn carrying the matching `tool_calls` precedes it, so the
674
+ second `chat()` call would have been rejected by strict providers (vLLM among them).
675
+ History now stores the real `tool_calls` array (id, name, arguments). `ToolCallItem.raw_item`
676
+ is a `ResponseFunctionToolCall` **object** when the Runner builds it and a dict elsewhere;
677
+ reading it only as a dict silently produced `arguments: "{}"`, so a replayed turn claimed
678
+ the model had called a tool with no arguments. `_item_field()` handles both.
679
+
680
+ 3. ✅ **`python_code`, `run_script` and `web_crawl` were dead** — moving tool execution
681
+ under the Runner put them *inside* an event loop, where `asyncio.run()` raises
682
+ `cannot be called from a running event loop`. Every `python_code` call returned
683
+ `SANDBOX_ERROR: ...` and the model just got an error string back. Added
684
+ `regcode/async_utils.py::run_coroutine_blocking()`, which runs the coroutine on a
685
+ dedicated thread when a loop is already active.
686
+
687
+ Also: `chat(messages=...)` is public API (`code_review`, GitKnecht, saved transcripts)
688
+ and accepts plain chat-shaped dicts, which `Converter.items_to_messages` rejects outright.
689
+ `_expand_input_items()` normalises `role: "tool"` and assistant `tool_calls` into Responses
690
+ items before conversion.
691
+
692
+ **Process lesson**: the new tests were written against the shapes the *tests* invented.
693
+ `tests/test_task_regression.py` now pins the message shape of every turn handed to litellm
694
+ (`_assert_chat_completions_shape`), covers text-plus-tool-call turns, a second `chat()`
695
+ call, and tool execution under a running loop. Each was mutation-checked — revert the fix,
696
+ watch it go red.
647
697
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: regcode
3
- Version: 0.12.0
3
+ Version: 0.12.1
4
4
  Summary: Minimalistic coding agent with Python API and CLI
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: boto3>=1.43.40
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "regcode"
3
- version = "0.12.0"
3
+ version = "0.12.1"
4
4
  description = "Minimalistic coding agent with Python API and CLI"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -2,4 +2,4 @@ from regcode.config import Config
2
2
  from regcode.main import Agent, AgentStatus, ToolCall
3
3
 
4
4
  __all__ = ["Agent", "Config", "AgentStatus", "ToolCall"]
5
- __version__ = "0.12.0"
5
+ __version__ = "0.12.1"
@@ -103,6 +103,69 @@ def _base_response(model: str) -> Response:
103
103
  )
104
104
 
105
105
 
106
+ def _output_text_part(text: str) -> dict[str, Any]:
107
+ """Build a Responses ``output_text`` content part."""
108
+ return {"annotations": [], "text": text, "type": "output_text"}
109
+
110
+
111
+ def _expand_input_items(items: list[Any]) -> list[Any]:
112
+ """Normalise chat-completions-shaped dicts into Responses input items.
113
+
114
+ ``chat(messages=...)`` is public API and callers (``code_review``,
115
+ GitKnecht, saved transcripts) pass plain ``{"role": ..., "content": ...}``
116
+ dicts, including the ``role: "tool"`` entries older versions stored in
117
+ ``Agent.history``. ``Converter.items_to_messages`` only understands
118
+ Responses items and raises ``UserError`` on those, which would kill the
119
+ whole run. Plain ``{role, content}`` dicts pass through untouched.
120
+ """
121
+ expanded: list[Any] = []
122
+ for item in items:
123
+ if not isinstance(item, dict):
124
+ expanded.append(item)
125
+ continue
126
+
127
+ role = item.get("role")
128
+ tool_calls = item.get("tool_calls")
129
+
130
+ if role == "tool":
131
+ expanded.append(
132
+ {
133
+ "type": "function_call_output",
134
+ "call_id": item.get("tool_call_id") or "call_unknown",
135
+ "output": item.get("content") or "",
136
+ }
137
+ )
138
+ continue
139
+
140
+ if tool_calls:
141
+ # A chat-shaped assistant turn bundles the text and the calls;
142
+ # Responses keeps them as separate items, calls first.
143
+ for call in tool_calls:
144
+ function = call.get("function") or {}
145
+ expanded.append(
146
+ {
147
+ "type": "function_call",
148
+ "call_id": call.get("id") or "call_unknown",
149
+ "name": function.get("name") or "",
150
+ "arguments": function.get("arguments") or "{}",
151
+ }
152
+ )
153
+ content = item.get("content")
154
+ if isinstance(content, str) and content:
155
+ expanded.append(
156
+ {
157
+ "type": "message",
158
+ "role": "assistant",
159
+ "status": "completed",
160
+ "content": [_output_text_part(content)],
161
+ }
162
+ )
163
+ continue
164
+
165
+ expanded.append(item)
166
+ return expanded
167
+
168
+
106
169
  class RegCodeModel(Model):
107
170
  """OpenAI Agents SDK ``Model`` backed by regcode's LiteLLM config.
108
171
 
@@ -279,7 +342,22 @@ class RegCodeModel(Model):
279
342
  input: str | list[TResponseInputItem],
280
343
  system_instructions: str | None,
281
344
  ) -> list[dict[str, Any]]:
282
- """Convert SDK input items into LiteLLM message dicts.
345
+ """Convert SDK input items into LiteLLM chat-completions messages.
346
+
347
+ The Runner hands back the assistant's own output as **Responses API**
348
+ items (``{"type": "message", "content": [{"type": "output_text", ...}]}``,
349
+ ``function_call``, ``function_call_output``). Those must be translated
350
+ before they reach litellm: copying them through verbatim made the
351
+ second turn of every tool-using conversation fail with
352
+
353
+ 3 validation errors for ValidatorIterator
354
+ .0.ChatCompletionContentPartTextParam.type
355
+ Input should be 'text' [input_value='output_text']
356
+
357
+ which surfaced in the CLI as an ``Agent error!`` the moment the model
358
+ said anything before calling a tool. ``Converter.items_to_messages`` is
359
+ the same translation the SDK's own ``LitellmModel`` uses, and it also
360
+ merges an assistant's text with the tool calls it asked for.
283
361
 
284
362
  The system turn is managed **by role**, never by position: an existing
285
363
  system message is replaced in place, and a caller list that starts with
@@ -290,19 +368,13 @@ class RegCodeModel(Model):
290
368
  """
291
369
  items: list[Any] = [{"role": "user", "content": input}] if isinstance(
292
370
  input, str
293
- ) else list(input)
294
-
295
- messages: list[dict[str, Any]] = []
296
- for item in items:
297
- if isinstance(item, dict):
298
- messages.append(dict(item))
299
- elif hasattr(item, "role"):
300
- messages.append(
301
- {
302
- "role": item.role,
303
- "content": getattr(item, "content", ""),
304
- }
305
- )
371
+ ) else _expand_input_items(list(input))
372
+
373
+ messages: list[dict[str, Any]] = list(
374
+ Converter.items_to_messages(
375
+ items, model=self.config.provider.model
376
+ )
377
+ )
306
378
 
307
379
  replacement = self._system_text(system_instructions)
308
380
  if replacement is None:
@@ -0,0 +1,58 @@
1
+ """Run async code from synchronous tool implementations.
2
+
3
+ Tools are invoked by the OpenAI Agents SDK ``Runner``, which drives the whole
4
+ conversation inside an event loop. A synchronous tool that calls
5
+ ``asyncio.run()`` therefore dies with::
6
+
7
+ RuntimeError: asyncio.run() cannot be called from a running event loop
8
+
9
+ That is exactly what silently broke ``python_code``, ``run_script`` (both via
10
+ ``MontySandbox``) and ``web_crawl`` once tool execution moved under the Runner.
11
+ The fix has to live in one place, because every tool that needs an event loop
12
+ has the same requirement.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import threading
19
+ from collections.abc import Callable
20
+ from typing import Any
21
+
22
+
23
+ def run_coroutine_blocking(factory: Callable[[], Any]) -> Any:
24
+ """Execute a coroutine to completion from sync code, loop present or not.
25
+
26
+ Args:
27
+ factory: Zero-argument callable returning a fresh coroutine. It is
28
+ called lazily so the coroutine is always created on the thread that
29
+ will run it.
30
+
31
+ Returns:
32
+ The coroutine's result.
33
+
34
+ Raises:
35
+ BaseException: Whatever the coroutine raised, re-raised on the calling
36
+ thread.
37
+ """
38
+ try:
39
+ asyncio.get_running_loop()
40
+ except RuntimeError:
41
+ # No loop on this thread — the plain, cheap path.
42
+ return asyncio.run(factory())
43
+
44
+ box: dict[str, Any] = {}
45
+
46
+ def _worker() -> None:
47
+ try:
48
+ box["value"] = asyncio.run(factory())
49
+ except BaseException as exc: # re-raised on the calling thread
50
+ box["error"] = exc
51
+
52
+ # A dedicated thread gets its own loop, leaving the Runner's loop alone.
53
+ thread = threading.Thread(target=_worker, daemon=True)
54
+ thread.start()
55
+ thread.join()
56
+ if "error" in box:
57
+ raise box["error"]
58
+ return box.get("value")
@@ -761,11 +761,18 @@ class Agent:
761
761
  def _update_history_from_sdk_result(
762
762
  self, result, messages: list[dict[str, Any]]
763
763
  ) -> None:
764
- """Extract conversation history from SDK RunResult and update _history."""
764
+ """Extract conversation history from SDK RunResult and update _history.
765
+
766
+ The result is stored in **chat-completions shape** because it is
767
+ replayed into the next ``chat()`` call. A ``role: "tool"`` entry is
768
+ only legal when an assistant turn carrying the matching ``tool_calls``
769
+ precedes it, so the tool call is recorded with its real id, name and
770
+ arguments rather than a ``"Tool call: x"`` label — strict providers
771
+ (vLLM among them) reject the orphaned tool result outright.
772
+ """
765
773
  from agents.items import MessageOutputItem, ToolCallItem, ToolCallOutputItem
766
774
 
767
775
  history = []
768
- last_tool_name: str | None = None
769
776
  for item in getattr(result, "new_items", None) or []:
770
777
  if isinstance(item, MessageOutputItem):
771
778
  # Extract text content from message
@@ -779,31 +786,40 @@ class Agent:
779
786
  "content": " ".join(text_parts),
780
787
  })
781
788
  elif isinstance(item, ToolCallItem):
782
- # Add tool call as assistant message
783
- last_tool_name = item.tool_name
789
+ call_id = (
790
+ _item_field(item, "call_id") or f"call_{len(history)}"
791
+ )
792
+ name = item.tool_name or _item_field(item, "name") or ""
784
793
  history.append({
785
794
  "role": "assistant",
786
- "content": f"Tool call: {item.tool_name}",
795
+ "content": None,
796
+ "tool_calls": [
797
+ {
798
+ "id": call_id,
799
+ "type": "function",
800
+ "function": {
801
+ "name": name,
802
+ "arguments": (
803
+ _item_field(item, "arguments") or "{}"
804
+ ),
805
+ },
806
+ }
807
+ ],
787
808
  })
788
809
  elif isinstance(item, ToolCallOutputItem):
789
- # ``raw_item`` is a ``function_call_output`` dict, not an
790
- # object — reading it by attribute raised AttributeError and
791
- # wiped the history after every tool call. The tool name lives
792
- # on the matching ToolCallItem, which the Runner emits just
793
- # before this one.
794
- raw = item.raw_item
795
- if isinstance(raw, dict):
796
- call_id = raw.get("call_id") or raw.get("id")
797
- else:
798
- call_id = getattr(raw, "call_id", None) or getattr(
799
- raw, "id", None
800
- )
810
+ # ``raw_item`` is a ``function_call_output`` — a dict in some
811
+ # paths and a pydantic model in others. Reading it by attribute
812
+ # alone raised AttributeError and wiped the history after every
813
+ # tool call; reading it as a dict alone silently yielded no
814
+ # call_id, orphaning the result.
815
+ call_id = _item_field(item, "call_id") or _item_field(
816
+ item, "id"
817
+ )
801
818
  output = item.output
802
819
  history.append(
803
820
  {
804
821
  "role": "tool",
805
822
  "tool_call_id": call_id,
806
- "name": last_tool_name,
807
823
  "content": output
808
824
  if isinstance(output, str)
809
825
  else str(output),
@@ -1150,6 +1166,26 @@ def _safe_final_output(result: Any) -> str:
1150
1166
  return output if isinstance(output, str) else ""
1151
1167
 
1152
1168
 
1169
+ def _item_field(item: Any, name: str) -> Any:
1170
+ """Read ``name`` off an SDK item whose ``raw_item`` may be dict or model.
1171
+
1172
+ ``ToolCallItem.raw_item`` is a ``ResponseFunctionToolCall`` object when the
1173
+ Runner built it, and a plain ``function_call`` dict in other paths. Reading
1174
+ only one of the two silently produced empty tool arguments, so a replayed
1175
+ history told the model it had called a tool with ``{}``.
1176
+ """
1177
+ raw = getattr(item, "raw_item", None)
1178
+ if isinstance(raw, dict):
1179
+ value = raw.get(name)
1180
+ if value is not None:
1181
+ return value
1182
+ elif raw is not None:
1183
+ value = getattr(raw, name, None)
1184
+ if value is not None:
1185
+ return value
1186
+ return getattr(item, name, None)
1187
+
1188
+
1153
1189
  def _run_coroutine(coro: Any) -> Any:
1154
1190
  """Run ``coro`` to completion from sync code, notebooks included.
1155
1191
 
@@ -31,6 +31,8 @@ from pydantic_monty import (
31
31
  ResourceLimits,
32
32
  )
33
33
 
34
+ from regcode.async_utils import run_coroutine_blocking
35
+
34
36
 
35
37
  @dataclass
36
38
  class MontySandboxConfig:
@@ -200,21 +202,21 @@ class MontySandbox:
200
202
  # Create Monty runner
201
203
  monty = Monty(code)
202
204
 
203
- # Execute code - always use asyncio.run() for full isolation
204
- # This avoids "no running event loop" errors that occur when
205
- # the Monty library checks for a running loop at the process level
206
205
  # Create callback outside async scope so we can access .output
207
206
  cb = CollectString()
208
207
 
208
+ async def _async_run():
209
+ return await monty.run_async(
210
+ external_functions=funcs,
211
+ limits=limits,
212
+ print_callback=cb,
213
+ )
214
+
209
215
  try:
210
- async def _async_run():
211
- return await monty.run_async(
212
- external_functions=funcs,
213
- limits=limits,
214
- print_callback=cb,
215
- )
216
-
217
- result = asyncio.run(_async_run())
216
+ # Must not call asyncio.run() directly: tools execute inside the
217
+ # SDK Runner's event loop, where a nested asyncio.run() raises
218
+ # "cannot be called from a running event loop".
219
+ result = run_coroutine_blocking(_async_run)
218
220
  except Exception as e:
219
221
  return MontySandboxResult(
220
222
  stdout=f"SANDBOX_ERROR: {str(e)}",
@@ -96,11 +96,14 @@ class WebCrawlerTool(BaseTool):
96
96
  exit_code=1,
97
97
  )
98
98
 
99
- import asyncio
99
+ from regcode.async_utils import run_coroutine_blocking
100
100
 
101
101
  try:
102
- result = asyncio.run(
103
- self._crawl_async(url, markdown, word_count_threshold, only_text)
102
+ # Loop-safe: this tool runs inside the SDK Runner's event loop.
103
+ result = run_coroutine_blocking(
104
+ lambda: self._crawl_async(
105
+ url, markdown, word_count_threshold, only_text
106
+ )
104
107
  )
105
108
  return result
106
109
  except Exception as e:
@@ -159,7 +159,15 @@ class TestAgentMultimodal:
159
159
  messages = mock_completion.call_args[1]["messages"]
160
160
  user_msg = [m for m in messages if m["role"] == "user"][0]
161
161
  assert isinstance(user_msg["content"], list)
162
- assert user_msg["content"] == multimodal_content
162
+ # The SDK's converter normalises image parts (it fills in
163
+ # detail="auto"), so assert the payload survives rather than
164
+ # exact dict equality.
165
+ parts = user_msg["content"]
166
+ assert [p["type"] for p in parts] == ["image_url", "text"]
167
+ assert (
168
+ parts[0]["image_url"]["url"] == "data:image/png;base64,abc"
169
+ )
170
+ assert parts[1]["text"] == "Describe this image."
163
171
 
164
172
  def test_chat_with_invalid_message_type(self):
165
173
  from regcode.main import Agent
@@ -73,7 +73,14 @@ def test_agent_chat_with_tool_use():
73
73
  "assistant",
74
74
  ]
75
75
  assert agent.history[0]["content"] == "calculate 1+1"
76
- assert agent.history[1]["content"] == "Tool call: python_code"
76
+ # The assistant turn must carry a real tool_calls entry: a role="tool"
77
+ # result is only legal when the matching call precedes it, and strict
78
+ # providers reject the orphan otherwise.
79
+ assert agent.history[1]["tool_calls"][0]["function"]["name"] == "python_code"
80
+ assert agent.history[1]["tool_calls"][0]["function"]["arguments"] == (
81
+ '{"code": "print(1)" }'
82
+ )
83
+ assert agent.history[2]["tool_call_id"] == "call_123"
77
84
  assert agent.history[3]["content"] == "Done!"
78
85
 
79
86
 
@@ -461,3 +461,295 @@ def test_module_level_helpers_exist():
461
461
  for gone in ("_stream", "_chat_loop", "_execute_tool_calls"):
462
462
  assert not hasattr(Agent, gone), f"{gone} should have been removed"
463
463
  assert not hasattr(main_mod, "_convert_registry_tools_to_sdk_tools")
464
+
465
+
466
+ # ── CLI regression: Responses items must never reach litellm ────────────
467
+
468
+ # Keys that belong to the Responses API. If one of these appears in a message
469
+ # handed to litellm, the provider answers
470
+ # "ChatCompletionContentPartTextParam.type Input should be 'text'
471
+ # [input_value='output_text']"
472
+ # which surfaced in the CLI as "Agent error! litellm.BadRequestError".
473
+ _RESPONSES_ONLY_KEYS = {"output_text", "function_call", "function_call_output"}
474
+
475
+
476
+ def _assert_chat_completions_shape(messages: list[dict], turn: int) -> None:
477
+ """Fail loudly if any Responses-API item leaked into a litellm request."""
478
+ for msg in messages:
479
+ assert "type" not in msg or msg["type"] in (
480
+ "message",
481
+ ), f"turn {turn}: Responses item leaked: {msg}"
482
+ content = msg.get("content")
483
+ if isinstance(content, list):
484
+ for part in content:
485
+ assert part.get("type") not in _RESPONSES_ONLY_KEYS, (
486
+ f"turn {turn}: Responses content part leaked: {part}"
487
+ )
488
+ if msg.get("role") == "tool":
489
+ assert msg.get("tool_call_id"), (
490
+ f"turn {turn}: tool result without tool_call_id: {msg}"
491
+ )
492
+
493
+
494
+ class TestToolTurnMessageShape:
495
+ """The Runner replays the assistant's own output back to the model.
496
+
497
+ Every test that existed before made the model return *either* text *or* a
498
+ tool call, so the second turn — the one carrying the replayed assistant
499
+ item — never happened, and the CLI broke on the first real conversation.
500
+ """
501
+
502
+ def test_text_with_tool_call_replays_as_chat_messages(self):
503
+ agent = Agent(config=Config(), directory=".")
504
+ with patch("regcode.agents_backend.litellm.completion") as m:
505
+ m.side_effect = [
506
+ completion_response(
507
+ "Let me take a quick peek.",
508
+ tool_calls=[tool_call("c1", "system_info", "{}")],
509
+ ),
510
+ completion_response("done"),
511
+ ]
512
+ agent.chat("hi")
513
+ turns = [c.kwargs["messages"] for c in m.call_args_list]
514
+
515
+ assert len(turns) == 2, "the tool turn must be followed by a second call"
516
+ for i, messages in enumerate(turns):
517
+ _assert_chat_completions_shape(messages, i)
518
+
519
+ replayed = turns[1][2]
520
+ assert replayed["role"] == "assistant"
521
+ # The SDK's converter merges the text and the call into one turn.
522
+ assert replayed["content"] == "Let me take a quick peek."
523
+ assert replayed["tool_calls"][0]["id"] == "c1"
524
+ assert turns[1][3]["role"] == "tool"
525
+ assert turns[1][3]["tool_call_id"] == "c1"
526
+
527
+ def test_streaming_text_with_tool_call(self):
528
+ agent = Agent(config=Config(), directory=".")
529
+ chunks: list[str] = []
530
+ with patch(
531
+ "regcode.agents_backend.litellm.acompletion", new_callable=AsyncMock
532
+ ) as m:
533
+ m.side_effect = [
534
+ stream_chunks(
535
+ "peeking", tool_calls=[tool_call("c1", "system_info", "{}")]
536
+ ),
537
+ stream_chunks("streamed done"),
538
+ ]
539
+ out = agent.chat("hi", stream=True, on_text_chunk=chunks.append)
540
+
541
+ assert out == "peekingstreamed done"
542
+ assert "".join(chunks) == out
543
+ for i, call in enumerate(m.call_args_list):
544
+ _assert_chat_completions_shape(call.kwargs["messages"], i)
545
+
546
+ def test_second_chat_call_replays_history_validly(self):
547
+ """Multi-turn is where the hand-built history was silently invalid.
548
+
549
+ A ``role: "tool"`` entry is only legal when an assistant turn carrying
550
+ the matching ``tool_calls`` precedes it; strict providers (vLLM) reject
551
+ the orphan outright.
552
+ """
553
+ agent = Agent(config=Config(), directory=".")
554
+ with patch("regcode.agents_backend.litellm.completion") as m:
555
+ m.side_effect = [
556
+ completion_response(
557
+ "peeking", tool_calls=[tool_call("c1", "system_info", "{}")]
558
+ ),
559
+ completion_response("first done"),
560
+ completion_response(
561
+ "looking again",
562
+ tool_calls=[tool_call("c2", "list_dir", '{"path": "."}')],
563
+ ),
564
+ completion_response("second done"),
565
+ ]
566
+ assert agent.chat("hi") == "first done"
567
+ assert agent.chat("again") == "second done"
568
+ turns = [c.kwargs["messages"] for c in m.call_args_list]
569
+
570
+ for i, messages in enumerate(turns):
571
+ _assert_chat_completions_shape(messages, i)
572
+
573
+ last = turns[-1]
574
+ tool_entries = [msg for msg in last if msg.get("role") == "tool"]
575
+ assert tool_entries, "history should carry the earlier tool result"
576
+ assistant_with_calls = [
577
+ msg for msg in last if msg.get("role") == "assistant" and msg.get(
578
+ "tool_calls"
579
+ )
580
+ ]
581
+ known_ids = {
582
+ tc["id"]
583
+ for msg in assistant_with_calls
584
+ for tc in msg["tool_calls"]
585
+ }
586
+ for msg in tool_entries:
587
+ assert msg["tool_call_id"] in known_ids, (
588
+ f"orphaned tool result: {msg['tool_call_id']} not in {known_ids}"
589
+ )
590
+
591
+ def test_history_preserves_real_tool_arguments(self):
592
+ """A replayed history must not claim the tool was called with ``{}``."""
593
+ agent = Agent(config=Config(), directory=".")
594
+ with patch("regcode.agents_backend.litellm.completion") as m:
595
+ m.side_effect = [
596
+ completion_response(
597
+ tool_calls=[
598
+ tool_call("c1", "python_code", '{"code": "print(1)"}')
599
+ ]
600
+ ),
601
+ completion_response("done"),
602
+ ]
603
+ agent.chat("calc")
604
+
605
+ call = agent.history[1]["tool_calls"][0]
606
+ assert call["function"]["arguments"] == '{"code": "print(1)"}'
607
+ assert call["function"]["name"] == "python_code"
608
+
609
+
610
+ class TestLegacyMessageShapes:
611
+ """``chat(messages=...)`` is public API and accepts plain chat dicts."""
612
+
613
+ def test_role_tool_history_is_accepted(self):
614
+ from regcode.agents_backend import _expand_input_items
615
+
616
+ expanded = _expand_input_items(
617
+ [{"role": "tool", "tool_call_id": "c9", "content": "output text"}]
618
+ )
619
+ assert expanded == [
620
+ {
621
+ "type": "function_call_output",
622
+ "call_id": "c9",
623
+ "output": "output text",
624
+ }
625
+ ]
626
+
627
+ def test_assistant_tool_calls_are_split(self):
628
+ from regcode.agents_backend import _expand_input_items
629
+
630
+ expanded = _expand_input_items(
631
+ [
632
+ {
633
+ "role": "assistant",
634
+ "content": "thinking",
635
+ "tool_calls": [
636
+ {
637
+ "id": "c1",
638
+ "type": "function",
639
+ "function": {
640
+ "name": "read_file",
641
+ "arguments": '{"path": "a"}',
642
+ },
643
+ }
644
+ ],
645
+ }
646
+ ]
647
+ )
648
+ assert expanded[0]["type"] == "function_call"
649
+ assert expanded[0]["name"] == "read_file"
650
+ assert expanded[1]["content"][0]["text"] == "thinking"
651
+
652
+ def test_plain_dicts_pass_through(self):
653
+ from regcode.agents_backend import _expand_input_items
654
+
655
+ items = [{"role": "user", "content": "hi"}]
656
+ assert _expand_input_items(items) == items
657
+
658
+ def test_chat_with_prebuilt_messages_survives_tool_history(self):
659
+ """code_review()/GitKnecht pass messages built from saved history."""
660
+ agent = Agent(config=Config(), directory=".")
661
+ messages = [
662
+ {"role": "system", "content": "SYS"},
663
+ {"role": "user", "content": "start"},
664
+ {
665
+ "role": "assistant",
666
+ "content": None,
667
+ "tool_calls": [
668
+ {
669
+ "id": "c1",
670
+ "type": "function",
671
+ "function": {
672
+ "name": "system_info",
673
+ "arguments": "{}",
674
+ },
675
+ }
676
+ ],
677
+ },
678
+ {"role": "tool", "tool_call_id": "c1", "content": "OS: Linux"},
679
+ ]
680
+ with patch("regcode.agents_backend.litellm.completion") as m:
681
+ m.return_value = completion_response("ok")
682
+ agent.chat(messages=messages)
683
+ sent = m.call_args.kwargs["messages"]
684
+
685
+ _assert_chat_completions_shape(sent, 0)
686
+ assert any(msg.get("role") == "tool" for msg in sent)
687
+
688
+
689
+ class TestToolsRunInsideEventLoop:
690
+ """Tools execute inside the Runner's loop, so asyncio.run() is illegal.
691
+
692
+ ``python_code``/``run_script`` (via MontySandbox) and ``web_crawl`` all
693
+ called ``asyncio.run()`` directly and every one of them returned
694
+ ``SANDBOX_ERROR: asyncio.run() cannot be called from a running event loop``.
695
+ """
696
+
697
+ def test_python_code_executes_under_runner(self):
698
+ agent = Agent(config=Config(), directory=".")
699
+ with patch("regcode.agents_backend.litellm.completion") as m:
700
+ m.side_effect = [
701
+ completion_response(
702
+ tool_calls=[
703
+ tool_call("c1", "python_code", '{"code": "print(42)"}')
704
+ ]
705
+ ),
706
+ completion_response("done"),
707
+ ]
708
+ agent.chat("run it")
709
+
710
+ tool_msg = next(h for h in agent.history if h["role"] == "tool")
711
+ assert "SANDBOX_ERROR" not in tool_msg["content"]
712
+ assert "42" in tool_msg["content"]
713
+ assert "exit_code: 0" in tool_msg["content"]
714
+
715
+ def test_run_coroutine_blocking_outside_loop(self):
716
+ import asyncio
717
+
718
+ from regcode.async_utils import run_coroutine_blocking
719
+
720
+ async def work() -> int:
721
+ await asyncio.sleep(0)
722
+ return 7
723
+
724
+ assert run_coroutine_blocking(work) == 7
725
+
726
+ def test_run_coroutine_blocking_inside_loop(self):
727
+ import asyncio
728
+
729
+ from regcode.async_utils import run_coroutine_blocking
730
+
731
+ async def work() -> int:
732
+ await asyncio.sleep(0)
733
+ return 7
734
+
735
+ async def outer() -> int:
736
+ # A running loop must not make the nested call fail.
737
+ return run_coroutine_blocking(work)
738
+
739
+ assert asyncio.run(outer()) == 7
740
+
741
+ def test_run_coroutine_blocking_propagates_errors(self):
742
+ import asyncio
743
+
744
+ import pytest
745
+
746
+ from regcode.async_utils import run_coroutine_blocking
747
+
748
+ async def boom() -> None:
749
+ raise ValueError("kaboom")
750
+
751
+ async def outer() -> None:
752
+ run_coroutine_blocking(boom)
753
+
754
+ with pytest.raises(ValueError, match="kaboom"):
755
+ asyncio.run(outer())
@@ -1,5 +1,5 @@
1
1
  version = 1
2
- revision = 3
2
+ revision = 5
3
3
  requires-python = ">=3.10"
4
4
  resolution-markers = [
5
5
  "python_full_version >= '3.14' and sys_platform == 'win32'",
@@ -682,7 +682,7 @@ resolution-markers = [
682
682
  "python_full_version < '3.11'",
683
683
  ]
684
684
  dependencies = [
685
- { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
685
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } },
686
686
  ]
687
687
  sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" }
688
688
  wheels = [
@@ -756,7 +756,7 @@ resolution-markers = [
756
756
  "python_full_version == '3.11.*'",
757
757
  ]
758
758
  dependencies = [
759
- { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
759
+ { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
760
760
  { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
761
761
  ]
762
762
  sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" }
@@ -1001,7 +1001,7 @@ name = "exceptiongroup"
1001
1001
  version = "1.3.1"
1002
1002
  source = { registry = "https://pypi.org/simple" }
1003
1003
  dependencies = [
1004
- { name = "typing-extensions", marker = "python_full_version < '3.11'" },
1004
+ { name = "typing-extensions" },
1005
1005
  ]
1006
1006
  sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
1007
1007
  wheels = [
@@ -1496,7 +1496,7 @@ name = "gunicorn"
1496
1496
  version = "26.0.0"
1497
1497
  source = { registry = "https://pypi.org/simple" }
1498
1498
  dependencies = [
1499
- { name = "packaging", marker = "python_full_version < '3.12' or sys_platform != 'win32'" },
1499
+ { name = "packaging" },
1500
1500
  ]
1501
1501
  sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" }
1502
1502
  wheels = [
@@ -2228,15 +2228,15 @@ resolution-markers = [
2228
2228
  "python_full_version < '3.11'",
2229
2229
  ]
2230
2230
  dependencies = [
2231
- { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
2232
- { name = "cycler", marker = "python_full_version < '3.11'" },
2233
- { name = "fonttools", marker = "python_full_version < '3.11'" },
2234
- { name = "kiwisolver", marker = "python_full_version < '3.11'" },
2235
- { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
2236
- { name = "packaging", marker = "python_full_version < '3.11'" },
2237
- { name = "pillow", marker = "python_full_version < '3.11'" },
2238
- { name = "pyparsing", marker = "python_full_version < '3.11'" },
2239
- { name = "python-dateutil", marker = "python_full_version < '3.11'" },
2231
+ { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" } },
2232
+ { name = "cycler" },
2233
+ { name = "fonttools" },
2234
+ { name = "kiwisolver" },
2235
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } },
2236
+ { name = "packaging" },
2237
+ { name = "pillow" },
2238
+ { name = "pyparsing" },
2239
+ { name = "python-dateutil" },
2240
2240
  ]
2241
2241
  sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" }
2242
2242
  wheels = [
@@ -2308,16 +2308,16 @@ resolution-markers = [
2308
2308
  "python_full_version == '3.11.*'",
2309
2309
  ]
2310
2310
  dependencies = [
2311
- { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
2312
- { name = "cycler", marker = "python_full_version >= '3.11'" },
2313
- { name = "fonttools", marker = "python_full_version >= '3.11'" },
2314
- { name = "kiwisolver", marker = "python_full_version >= '3.11'" },
2315
- { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
2311
+ { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" } },
2312
+ { name = "cycler" },
2313
+ { name = "fonttools" },
2314
+ { name = "kiwisolver" },
2315
+ { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
2316
2316
  { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
2317
- { name = "packaging", marker = "python_full_version >= '3.11'" },
2318
- { name = "pillow", marker = "python_full_version >= '3.11'" },
2319
- { name = "pyparsing", marker = "python_full_version >= '3.11'" },
2320
- { name = "python-dateutil", marker = "python_full_version >= '3.11'" },
2317
+ { name = "packaging" },
2318
+ { name = "pillow" },
2319
+ { name = "pyparsing" },
2320
+ { name = "python-dateutil" },
2321
2321
  ]
2322
2322
  sdist = { url = "https://files.pythonhosted.org/packages/1f/24/080c99d223d158d3a8902769269ab6da5b50f7a0e6e072513907e02b7a6c/matplotlib-3.11.0.tar.gz", hash = "sha256:68c0c7be01b30dcca3638934f7f591df73401235cbdbf0d1ab1c71e7db7f8b57", size = 33251176, upload-time = "2026-06-12T02:29:15.508Z" }
2323
2323
  wheels = [
@@ -3962,7 +3962,7 @@ wheels = [
3962
3962
 
3963
3963
  [[package]]
3964
3964
  name = "regcode"
3965
- version = "0.11.0"
3965
+ version = "0.12.1"
3966
3966
  source = { editable = "." }
3967
3967
  dependencies = [
3968
3968
  { name = "boto3" },
@@ -4481,10 +4481,10 @@ resolution-markers = [
4481
4481
  "python_full_version < '3.11'",
4482
4482
  ]
4483
4483
  dependencies = [
4484
- { name = "joblib", marker = "python_full_version < '3.11'" },
4485
- { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
4486
- { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
4487
- { name = "threadpoolctl", marker = "python_full_version < '3.11'" },
4484
+ { name = "joblib" },
4485
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } },
4486
+ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" } },
4487
+ { name = "threadpoolctl" },
4488
4488
  ]
4489
4489
  sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" }
4490
4490
  wheels = [
@@ -4532,13 +4532,13 @@ resolution-markers = [
4532
4532
  "python_full_version == '3.11.*'",
4533
4533
  ]
4534
4534
  dependencies = [
4535
- { name = "joblib", marker = "python_full_version >= '3.11'" },
4536
- { name = "narwhals", marker = "python_full_version >= '3.11'" },
4537
- { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
4535
+ { name = "joblib" },
4536
+ { name = "narwhals" },
4537
+ { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
4538
4538
  { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
4539
- { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
4539
+ { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
4540
4540
  { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
4541
- { name = "threadpoolctl", marker = "python_full_version >= '3.11'" },
4541
+ { name = "threadpoolctl" },
4542
4542
  ]
4543
4543
  sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" }
4544
4544
  wheels = [
@@ -4582,7 +4582,7 @@ resolution-markers = [
4582
4582
  "python_full_version < '3.11'",
4583
4583
  ]
4584
4584
  dependencies = [
4585
- { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
4585
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } },
4586
4586
  ]
4587
4587
  sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" }
4588
4588
  wheels = [
@@ -4641,7 +4641,7 @@ resolution-markers = [
4641
4641
  "python_full_version == '3.11.*'",
4642
4642
  ]
4643
4643
  dependencies = [
4644
- { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
4644
+ { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } },
4645
4645
  ]
4646
4646
  sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
4647
4647
  wheels = [
@@ -4718,7 +4718,7 @@ resolution-markers = [
4718
4718
  "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32'",
4719
4719
  ]
4720
4720
  dependencies = [
4721
- { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
4721
+ { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" } },
4722
4722
  ]
4723
4723
  sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" }
4724
4724
  wheels = [
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