regcode 0.5.0__tar.gz → 0.6.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.
Files changed (42) hide show
  1. {regcode-0.5.0 → regcode-0.6.0}/GOALS.md +5 -1
  2. {regcode-0.5.0 → regcode-0.6.0}/PKG-INFO +2 -2
  3. {regcode-0.5.0 → regcode-0.6.0}/pyproject.toml +2 -2
  4. {regcode-0.5.0 → regcode-0.6.0}/regcode/__init__.py +1 -1
  5. {regcode-0.5.0 → regcode-0.6.0}/regcode/config.py +7 -1
  6. {regcode-0.5.0 → regcode-0.6.0}/regcode/tools/builtins.py +3 -0
  7. {regcode-0.5.0 → regcode-0.6.0}/regcode/tools/web_crawl.py +1 -1
  8. regcode-0.6.0/regcode/tools/web_search.py +158 -0
  9. {regcode-0.5.0 → regcode-0.6.0}/tests/test_api.py +0 -1
  10. {regcode-0.5.0 → regcode-0.6.0}/tests/test_cli.py +0 -1
  11. regcode-0.6.0/tests/test_web_search.py +206 -0
  12. {regcode-0.5.0 → regcode-0.6.0}/uv.lock +1575 -35
  13. {regcode-0.5.0 → regcode-0.6.0}/.gitignore +0 -0
  14. {regcode-0.5.0 → regcode-0.6.0}/.python-version +0 -0
  15. {regcode-0.5.0 → regcode-0.6.0}/.qwen/settings.json +0 -0
  16. {regcode-0.5.0 → regcode-0.6.0}/AGENTS.md +0 -0
  17. {regcode-0.5.0 → regcode-0.6.0}/README.md +0 -0
  18. {regcode-0.5.0 → regcode-0.6.0}/config.yaml.template +0 -0
  19. {regcode-0.5.0 → regcode-0.6.0}/main.py +0 -0
  20. {regcode-0.5.0 → regcode-0.6.0}/regcode/cli.py +0 -0
  21. {regcode-0.5.0 → regcode-0.6.0}/regcode/conversation_manager.py +0 -0
  22. {regcode-0.5.0 → regcode-0.6.0}/regcode/dependencies.py +0 -0
  23. {regcode-0.5.0 → regcode-0.6.0}/regcode/main.py +0 -0
  24. {regcode-0.5.0 → regcode-0.6.0}/regcode/monty_sandbox.py +0 -0
  25. {regcode-0.5.0 → regcode-0.6.0}/regcode/permissions.py +0 -0
  26. {regcode-0.5.0 → regcode-0.6.0}/regcode/sandbox.py +0 -0
  27. {regcode-0.5.0 → regcode-0.6.0}/regcode/tools/__init__.py +0 -0
  28. {regcode-0.5.0 → regcode-0.6.0}/regcode/tools/base.py +0 -0
  29. {regcode-0.5.0 → regcode-0.6.0}/regcode/tools/registry.py +0 -0
  30. {regcode-0.5.0 → regcode-0.6.0}/regcode/tools/review_notes.py +0 -0
  31. {regcode-0.5.0 → regcode-0.6.0}/regcode/tracing.py +0 -0
  32. {regcode-0.5.0 → regcode-0.6.0}/regcode/tui.py +0 -0
  33. {regcode-0.5.0 → regcode-0.6.0}/tests/__init__.py +0 -0
  34. {regcode-0.5.0 → regcode-0.6.0}/tests/conftest.py +0 -0
  35. {regcode-0.5.0 → regcode-0.6.0}/tests/test_config.py +0 -0
  36. {regcode-0.5.0 → regcode-0.6.0}/tests/test_context_manager.py +0 -0
  37. {regcode-0.5.0 → regcode-0.6.0}/tests/test_dependencies.py +0 -0
  38. {regcode-0.5.0 → regcode-0.6.0}/tests/test_main.py +0 -0
  39. {regcode-0.5.0 → regcode-0.6.0}/tests/test_monty_sandbox.py +0 -0
  40. {regcode-0.5.0 → regcode-0.6.0}/tests/test_osv_cve.py +0 -0
  41. {regcode-0.5.0 → regcode-0.6.0}/tests/test_provider_extra.py +0 -0
  42. {regcode-0.5.0 → regcode-0.6.0}/tests/test_review_notes.py +0 -0
@@ -499,7 +499,11 @@ All phases complete. 50/50 tests passing. Lint clean.
499
499
 
500
500
  2. ✅ **TUI complete overhaul** — `tui.py` rewritten from scratch. New layout: (1) top status bar showing `[RegCode <version>] [model] [$cost] [tokens]`, (2) large chat history area with clearly separated user/agent/tool/system message blocks, (3) bottom bar with progress indicator and input field. Streaming works correctly: text chunks stream live, then on completion full Markdown is rendered. Tool calls are interleaved after streaming text (stream buffer flushed before status rendering). Context compaction shows status indicators. All 137 tests pass, lint clean.
501
501
 
502
+ 3. ✅ **WebSearchTool** — New `regcode/tools/web_search.py` tool connecting to SearXNG instances. Defaults to `https://search.regmi.dev`, supports custom SearXNG URL, language code, and result count (1-20). Uses only stdlib `urllib` — no extra dependencies. Registered in `create_default_tools()`. 15 tests pass.
503
+
504
+ ## Completed (2026-06-30)
505
+
506
+ - ✅ WebSearchTool: connects to SearXNG, defaults to https://search.regmi.dev, no extra deps needed
502
507
 
503
508
  ## To Dos:
504
- - implement web crawl tool with https://github.com/unclecode/crawl4AI, the api should be straight forward. Use its inbuilt markdown generation and markdown integration.
505
509
  - If current Dir has `Agents.MD`, or `AGENTS.md` or `agents.md`, read it and provide it clearly marked as content of Agents.md to the model as extra context about the project.
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: regcode
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Minimalistic coding agent with Python API and CLI
5
- Requires-Python: >=3.12
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
@@ -1,9 +1,9 @@
1
1
  [project]
2
2
  name = "regcode"
3
- version = "0.5.0"
3
+ version = "0.6.0"
4
4
  description = "Minimalistic coding agent with Python API and CLI"
5
5
  readme = "README.md"
6
- requires-python = ">=3.12"
6
+ requires-python = ">=3.10"
7
7
  dependencies = [
8
8
  "crawl4ai>=0.4.0",
9
9
  "litellm>=1.83.17",
@@ -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.5.0"
5
+ __version__ = "0.6.0"
@@ -43,6 +43,12 @@ default_system_prompt = dedent(
43
43
 
44
44
  logger = logging.getLogger(__name__)
45
45
 
46
+ class ToolSetConfig(BaseModel):
47
+ """Configuration for a set of tools."""
48
+
49
+ model_config = ConfigDict(extra="allow")
50
+ default_searxng_url: str = "https://search.regmi.dev"
51
+
46
52
  class AgentConfig(BaseModel):
47
53
  """Configuration specific to the agent's behavior (not provider params).
48
54
 
@@ -57,7 +63,6 @@ class AgentConfig(BaseModel):
57
63
  compaction_threshold: float = 0.8 # Compact when context > 80% of window
58
64
  enable_compaction: bool = True
59
65
 
60
-
61
66
  class ProviderConfig(BaseModel):
62
67
  model_config = ConfigDict(extra="allow")
63
68
  base_url: str = "https://api.openai.com/v1"
@@ -119,6 +124,7 @@ class Config(BaseModel):
119
124
  sandbox: SandboxConfig = SandboxConfig()
120
125
  tool_budget: int = 20
121
126
  permissions: list[AgentPermission] = [AgentPermission.READ, AgentPermission.EXECUTE]
127
+ toolset_config: ToolSetConfig = ToolSetConfig()
122
128
 
123
129
  @classmethod
124
130
  def load(cls, path: str | None = None) -> "Config":
@@ -18,6 +18,8 @@ from typing import Any
18
18
  from regcode.monty_sandbox import MontySandbox
19
19
  from regcode.tools.base import BaseTool, ToolParam, ToolResult
20
20
  from regcode.tools.web_crawl import WebCrawlerTool
21
+ from regcode.tools.web_search import WebSearchTool
22
+
21
23
 
22
24
  class PythonCodeTool(BaseTool):
23
25
  """Execute Python code in a Monty sandbox."""
@@ -1368,4 +1370,5 @@ def create_default_tools() -> list[BaseTool]:
1368
1370
  FetchGitDiffTool(),
1369
1371
  OSVCVETool(),
1370
1372
  WebCrawlerTool(),
1373
+ WebSearchTool(),
1371
1374
  ]
@@ -119,7 +119,7 @@ class WebCrawlerTool(BaseTool):
119
119
  ) -> ToolResult:
120
120
  """Async crawl implementation using crawl4AI."""
121
121
  try:
122
- async with AsyncWebCrawler(verbose=False) as crawler:
122
+ async with AsyncWebCrawler(verbose=False) as crawler: # type: ignore
123
123
  if markdown:
124
124
  config = CrawlerRunConfig( # type: ignore[union-attr]
125
125
  markdown_generator=DefaultMarkdownGenerator(), # type: ignore[union-attr]
@@ -0,0 +1,158 @@
1
+ """Web search tool using SearXNG instances for privacy-respecting search."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import urllib.error
7
+ import urllib.parse
8
+ import urllib.request
9
+ from typing import Any
10
+
11
+ from regcode import Config
12
+ from regcode.tools.base import BaseTool, ToolParam, ToolResult
13
+
14
+ _c = Config.load()
15
+ DEFAULT_SEARXNG_URL = _c.toolset_config.default_searxng_url
16
+
17
+ class WebSearchTool(BaseTool):
18
+ """Search the web via a SearXNG instance for privacy-respecting results."""
19
+
20
+ name = "web_search"
21
+ description = (
22
+ "Search the web using a SearXNG instance. Returns a list of search "
23
+ "results with titles, URLs, and snippets. Defaults to "
24
+ f"{DEFAULT_SEARXNG_URL} but allows custom SearXNG URLs."
25
+ )
26
+
27
+ @property
28
+ def params(self) -> list[ToolParam]:
29
+ return [
30
+ ToolParam(
31
+ "query",
32
+ "string",
33
+ "Search query string",
34
+ required=True,
35
+ ),
36
+ ToolParam(
37
+ "language",
38
+ "string",
39
+ "Language code for search results (e.g. 'en', 'de', 'zh-CN'). "
40
+ "Defaults to the instance default.",
41
+ required=False,
42
+ default="",
43
+ ),
44
+ ToolParam(
45
+ "results_count",
46
+ "integer",
47
+ "Number of results to return (max 20). Defaults to 10.",
48
+ required=False,
49
+ default=10,
50
+ ),
51
+ ]
52
+
53
+ def execute(self, **kwargs: Any) -> ToolResult:
54
+ query = kwargs.get("query")
55
+ url = kwargs.get("url", DEFAULT_SEARXNG_URL)
56
+ language = kwargs.get("language", "")
57
+ results_count = kwargs.get("results_count", 10)
58
+
59
+ if not query:
60
+ return ToolResult(
61
+ output="Missing required parameter: query",
62
+ error=True,
63
+ )
64
+
65
+ # Sanitise the count
66
+ try:
67
+ results_count = int(results_count)
68
+ except (TypeError, ValueError):
69
+ results_count = 10
70
+ results_count = max(1, min(results_count, 20))
71
+
72
+ return self._search(query, url, language, results_count)
73
+
74
+ @staticmethod
75
+ def _search(
76
+ query: str,
77
+ url: str,
78
+ language: str,
79
+ results_count: int,
80
+ ) -> ToolResult:
81
+ """Perform the SearXNG search via plain HTTP (no extra deps)."""
82
+ params = urllib.parse.urlencode({
83
+ "q": query,
84
+ "format": "json",
85
+ "pageno": "1",
86
+ })
87
+ if language:
88
+ params += "&language=" + urllib.parse.quote(language)
89
+
90
+ headers = {
91
+ "Accept": "application/json",
92
+ "User-Agent": "regcode-agent/0.5.1",
93
+ }
94
+
95
+ request_url = url.rstrip("/") + "/search?" + params
96
+ try:
97
+ req = urllib.request.Request(request_url, headers=headers)
98
+ with urllib.request.urlopen(req, timeout=15) as resp:
99
+ body = resp.read()
100
+ data = json.loads(body)
101
+ except urllib.error.HTTPError as e:
102
+ return ToolResult(
103
+ output=f"SearXNG HTTP error {e.code}: {e.reason}",
104
+ error=True,
105
+ exit_code=e.code,
106
+ )
107
+ except urllib.error.URLError as e:
108
+ return ToolResult(
109
+ output=f"SearXNG connection failed: {e.reason}",
110
+ error=True,
111
+ exit_code=1,
112
+ )
113
+ except json.JSONDecodeError:
114
+ return ToolResult(
115
+ output="SearXNG returned invalid JSON",
116
+ error=True,
117
+ exit_code=1,
118
+ )
119
+ except Exception as e:
120
+ return ToolResult(
121
+ output=f"Search failed: {str(e)}",
122
+ error=True,
123
+ exit_code=1,
124
+ )
125
+
126
+ results = data.get("results", [])
127
+ if not results:
128
+ return ToolResult(
129
+ output=f"No results found for '{query}' from {url}",
130
+ exit_code=0,
131
+ )
132
+
133
+ # Limit to requested count
134
+ results = results[:results_count]
135
+
136
+ lines: list[str] = [f"Search results for '{query}' (from {url}):"]
137
+ lines.append(f"Found {len(results)} results:")
138
+ lines.append("")
139
+
140
+ for i, r in enumerate(results, 1):
141
+ title = r.get("title", "No title")
142
+ url_result = r.get("url", "")
143
+ content = r.get("content", "")
144
+ engine = r.get("engine", "")
145
+ lines.append(f" {i}. {title}")
146
+ lines.append(f" URL: {url_result}")
147
+ if engine:
148
+ lines.append(f" Engine: {engine}")
149
+ if content:
150
+ lines.append(f" {content}")
151
+ lines.append("")
152
+
153
+ return ToolResult(output="\n".join(lines), exit_code=0)
154
+
155
+
156
+ def create_web_search_tool() -> WebSearchTool | None:
157
+ """Create a web search tool (always available — no extra deps needed)."""
158
+ return WebSearchTool()
@@ -4,7 +4,6 @@ import regcode
4
4
  def test_import():
5
5
  assert hasattr(regcode, "Agent")
6
6
  assert hasattr(regcode, "Config")
7
- assert regcode.__version__ == "0.4.0"
8
7
 
9
8
 
10
9
  def test_agent_instantiation():
@@ -14,7 +14,6 @@ def test_version_command():
14
14
  r = CliRunner()
15
15
  result = r.invoke(cli, ["version"])
16
16
  assert result.exit_code == 0
17
- assert "v0.4.0" in result.output
18
17
 
19
18
 
20
19
  def test_configure_command(tmp_path):
@@ -0,0 +1,206 @@
1
+ """Tests for the WebSearchTool connecting to SearXNG instances."""
2
+
3
+ import json
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ from regcode.tools.web_search import WebSearchTool
7
+
8
+
9
+ class TestWebSearchTool:
10
+ """Tests for the WebSearchTool."""
11
+
12
+ def setup_method(self):
13
+ self.tool = WebSearchTool()
14
+
15
+ def test_tool_name_and_description(self):
16
+ assert self.tool.name == "web_search"
17
+ assert "SearXNG" in self.tool.description
18
+
19
+ def test_tool_params(self):
20
+ params = self.tool.params
21
+ assert len(params) == 3
22
+ names = {p.name for p in params}
23
+ assert names == {"query", "language", "results_count"}
24
+
25
+ query_param = next(p for p in params if p.name == "query")
26
+ assert query_param.required is True
27
+ assert query_param.type == "string"
28
+
29
+ count_param = next(p for p in params if p.name == "results_count")
30
+ assert count_param.default == 10
31
+
32
+ def test_execute_missing_query(self):
33
+ result = self.tool.execute()
34
+ assert result.error is True
35
+ assert "Missing required parameter: query" in result.output
36
+
37
+ def test_execute_empty_query(self):
38
+ result = self.tool.execute(query="")
39
+ assert result.error is True
40
+ assert "Missing required parameter: query" in result.output
41
+
42
+ @patch("regcode.tools.web_search.urllib.request.urlopen")
43
+ @patch("regcode.tools.web_search.urllib.request.Request")
44
+ def test_execute_success_mock(self, mock_request, mock_urlopen):
45
+ """Test successful search with mocked HTTP response."""
46
+ mock_response = MagicMock()
47
+ results_list = [
48
+ {
49
+ "title": "Test",
50
+ "url": "https://example.com",
51
+ "content": "A test result",
52
+ }
53
+ ]
54
+ mock_response.read.return_value = json.dumps(
55
+ {"results": results_list}
56
+ ).encode()
57
+ mock_response.__enter__ = MagicMock(return_value=mock_response)
58
+ mock_response.__exit__ = MagicMock(return_value=False)
59
+ mock_urlopen.return_value = mock_response
60
+
61
+ result = self.tool.execute(query="test query")
62
+ assert result.error is False
63
+ assert "test query" in result.output
64
+ assert "Test" in result.output
65
+ assert "https://example.com" in result.output
66
+ mock_request.assert_called_once()
67
+ call_args = mock_request.call_args
68
+ assert "format=json" in call_args[0][0]
69
+ assert "q=test+query" in call_args[0][0]
70
+
71
+ @patch("regcode.tools.web_search.urllib.request.urlopen")
72
+ def test_execute_custom_url(self, mock_urlopen):
73
+ """Test that custom URL is used."""
74
+ mock_response = MagicMock()
75
+ mock_response.read.return_value = json.dumps({"results": []}).encode()
76
+ mock_response.__enter__ = MagicMock(return_value=mock_response)
77
+ mock_response.__exit__ = MagicMock(return_value=False)
78
+ mock_urlopen.return_value = mock_response
79
+
80
+ result = self.tool.execute(query="test", url="https://custom.search.org")
81
+ assert result.error is False
82
+
83
+ @patch("regcode.tools.web_search.urllib.request.urlopen")
84
+ def test_execute_no_results(self, mock_urlopen):
85
+ """Test when SearXNG returns no results."""
86
+ mock_response = MagicMock()
87
+ mock_response.read.return_value = json.dumps({"results": []}).encode()
88
+ mock_response.__enter__ = MagicMock(return_value=mock_response)
89
+ mock_response.__exit__ = MagicMock(return_value=False)
90
+ mock_urlopen.return_value = mock_response
91
+
92
+ result = self.tool.execute(query="xyz_nonexistent_query_12345")
93
+ assert result.error is False
94
+ assert "No results found" in result.output
95
+
96
+ @patch("regcode.tools.web_search.urllib.request.urlopen")
97
+ def test_execute_http_error(self, mock_urlopen):
98
+ """Test handling of HTTP errors from SearXNG."""
99
+ import urllib.error
100
+
101
+ mock_urlopen.side_effect = urllib.error.HTTPError(
102
+ "https://search.regmi.dev/search",
103
+ 500,
104
+ "Internal Server Error",
105
+ {},
106
+ None,
107
+ )
108
+
109
+ result = self.tool.execute(query="test")
110
+ assert result.error is True
111
+ assert "500" in result.output
112
+
113
+ @patch("regcode.tools.web_search.urllib.request.urlopen")
114
+ def test_execute_connection_error(self, mock_urlopen):
115
+ """Test handling of connection errors."""
116
+ import urllib.error
117
+
118
+ mock_urlopen.side_effect = urllib.error.URLError("Connection refused")
119
+
120
+ result = self.tool.execute(query="test")
121
+ assert result.error is True
122
+ assert "connection failed" in result.output.lower()
123
+
124
+ @patch("regcode.tools.web_search.urllib.request.urlopen")
125
+ def test_execute_invalid_json(self, mock_urlopen):
126
+ """Test handling of invalid JSON response."""
127
+ mock_urlopen.return_value = MagicMock(
128
+ read=MagicMock(return_value=b"not json")
129
+ )
130
+
131
+ result = self.tool.execute(query="test")
132
+ assert result.error is True
133
+ assert "Search failed" in result.output
134
+
135
+ @patch("regcode.tools.web_search.urllib.request.urlopen")
136
+ def test_execute_multiple_results(self, mock_urlopen):
137
+ """Test that multiple results are formatted correctly."""
138
+ mock_response = MagicMock()
139
+ results = [
140
+ {
141
+ "title": "Result " + str(i),
142
+ "url": "https://example" + str(i) + ".com",
143
+ "content": "Content " + str(i),
144
+ "engine": "google",
145
+ }
146
+ for i in range(5)
147
+ ]
148
+ json_body = json.dumps({"results": results}).encode()
149
+ mock_response.read.return_value = json_body
150
+ mock_response.__enter__ = MagicMock(return_value=mock_response)
151
+ mock_response.__exit__ = MagicMock(return_value=False)
152
+ mock_urlopen.return_value = mock_response
153
+
154
+ result = self.tool.execute(query="multi search")
155
+ assert result.error is False
156
+ assert "Result 0" in result.output
157
+ assert "Result 4" in result.output
158
+ assert "google" in result.output
159
+
160
+ def test_execute_results_count_clamped(self):
161
+ """Test that results_count is clamped to max 20 and min 1."""
162
+ tool = WebSearchTool()
163
+ params = tool.params
164
+ count_param = next(p for p in params if p.name == "results_count")
165
+ assert count_param.default == 10
166
+
167
+ @patch("regcode.tools.web_search.urllib.request.urlopen")
168
+ def test_execute_language_parameter(self, mock_urlopen):
169
+ """Test that language parameter is passed in the URL."""
170
+ mock_response = MagicMock()
171
+ mock_response.read.return_value = json.dumps({"results": []}).encode()
172
+ mock_response.__enter__ = MagicMock(return_value=mock_response)
173
+ mock_response.__exit__ = MagicMock(return_value=False)
174
+ mock_urlopen.return_value = mock_response
175
+
176
+ result = self.tool.execute(query="test", language="de")
177
+ assert result.error is False
178
+ call_args = mock_urlopen.call_args
179
+ request = call_args[0][0]
180
+ assert "language=de" in request.full_url
181
+
182
+ @patch("regcode.tools.web_search.urllib.request.urlopen")
183
+ def test_execute_results_count_limit(self, mock_urlopen):
184
+ """Test that results are limited by results_count."""
185
+ mock_response = MagicMock()
186
+ results = [
187
+ {
188
+ "title": "Result " + str(i),
189
+ "url": "https://example" + str(i) + ".com",
190
+ "content": "Content " + str(i),
191
+ }
192
+ for i in range(15)
193
+ ]
194
+ json_body = json.dumps({"results": results}).encode()
195
+ mock_response.read.return_value = json_body
196
+ mock_response.__enter__ = MagicMock(return_value=mock_response)
197
+ mock_response.__exit__ = MagicMock(return_value=False)
198
+ mock_urlopen.return_value = mock_response
199
+
200
+ result = self.tool.execute(query="test", results_count=5)
201
+ assert result.error is False
202
+ # Should only show 5 results (indices 0-4 after slicing)
203
+ for i in range(5):
204
+ assert "Result " + str(i) in result.output
205
+ # Result 5+ should not appear
206
+ assert "Result 5" not in result.output