deepy-cli 0.1.2__tar.gz → 0.1.3__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 (69) hide show
  1. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/PKG-INFO +10 -2
  2. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/README.md +9 -1
  3. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/pyproject.toml +1 -1
  4. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/__init__.py +1 -1
  5. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/cli.py +2 -4
  6. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/config/__init__.py +2 -0
  7. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/config/settings.py +3 -6
  8. deepy_cli-0.1.3/src/deepy/data/tools/WebSearch.md +15 -0
  9. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/llm/runner.py +41 -0
  10. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/tools/agents.py +5 -1
  11. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/tools/builtin.py +248 -172
  12. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/terminal.py +73 -5
  13. deepy_cli-0.1.2/src/deepy/data/tools/WebSearch.md +0 -9
  14. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/__main__.py +0 -0
  15. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/data/__init__.py +0 -0
  16. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/data/tools/AskUserQuestion.md +0 -0
  17. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/data/tools/WebFetch.md +0 -0
  18. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/data/tools/__init__.py +0 -0
  19. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/data/tools/bash.md +0 -0
  20. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/data/tools/edit.md +0 -0
  21. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/data/tools/modify.md +0 -0
  22. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/data/tools/read.md +0 -0
  23. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/data/tools/write.md +0 -0
  24. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/errors.py +0 -0
  25. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/llm/__init__.py +0 -0
  26. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/llm/agent.py +0 -0
  27. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/llm/context.py +0 -0
  28. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/llm/events.py +0 -0
  29. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/llm/model_capabilities.py +0 -0
  30. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/llm/provider.py +0 -0
  31. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/llm/replay.py +0 -0
  32. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/llm/thinking.py +0 -0
  33. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/prompts/__init__.py +0 -0
  34. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/prompts/compact.py +0 -0
  35. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/prompts/rules.py +0 -0
  36. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/prompts/runtime_context.py +0 -0
  37. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/prompts/system.py +0 -0
  38. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/prompts/tool_docs.py +0 -0
  39. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/sessions/__init__.py +0 -0
  40. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/sessions/jsonl.py +0 -0
  41. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/sessions/manager.py +0 -0
  42. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/skills.py +0 -0
  43. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/status.py +0 -0
  44. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/tools/__init__.py +0 -0
  45. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/tools/file_state.py +0 -0
  46. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/tools/result.py +0 -0
  47. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/tools/shell_utils.py +0 -0
  48. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/__init__.py +0 -0
  49. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/app.py +0 -0
  50. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/ask_user_question.py +0 -0
  51. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/exit_summary.py +0 -0
  52. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/loading_text.py +0 -0
  53. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/markdown.py +0 -0
  54. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/message_view.py +0 -0
  55. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/prompt_buffer.py +0 -0
  56. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/prompt_input.py +0 -0
  57. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/session_list.py +0 -0
  58. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/session_picker.py +0 -0
  59. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/slash_commands.py +0 -0
  60. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/styles.py +0 -0
  61. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/thinking_state.py +0 -0
  62. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/welcome.py +0 -0
  63. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/update_check.py +0 -0
  64. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/usage.py +0 -0
  65. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/utils/__init__.py +0 -0
  66. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/utils/debug_logger.py +0 -0
  67. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/utils/error_logger.py +0 -0
  68. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/utils/json.py +0 -0
  69. {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/utils/notify.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: deepy-cli
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Deepy - Vibe coding for DeepSeek models in your terminal
5
5
  Keywords: deepseek,coding-agent,terminal,cli,agents
6
6
  Author: kirineko
@@ -144,6 +144,14 @@ context_window_tokens = 1048576
144
144
  compact_threshold = 0.8
145
145
  ```
146
146
 
147
+ WebSearch uses Deepy's hosted SearXNG endpoint by default. You can override it
148
+ with your own SearXNG instance:
149
+
150
+ ```toml
151
+ [tools.web_search]
152
+ searxng_url = "https://your-searxng.example/"
153
+ ```
154
+
147
155
  You can also initialize config non-interactively:
148
156
 
149
157
  ```bash
@@ -200,6 +208,6 @@ assets live outside the package directory and are not included in the wheel.
200
208
 
201
209
  ## Release Status
202
210
 
203
- Deepy is preparing its first public `0.1.2` release. The current release path is
211
+ Deepy is preparing its first public `0.1.3` release. The current release path is
204
212
  GitHub + PyPI. Standalone binaries and npm wrappers can be added later, but the
205
213
  primary distribution is the Python CLI.
@@ -116,6 +116,14 @@ context_window_tokens = 1048576
116
116
  compact_threshold = 0.8
117
117
  ```
118
118
 
119
+ WebSearch uses Deepy's hosted SearXNG endpoint by default. You can override it
120
+ with your own SearXNG instance:
121
+
122
+ ```toml
123
+ [tools.web_search]
124
+ searxng_url = "https://your-searxng.example/"
125
+ ```
126
+
119
127
  You can also initialize config non-interactively:
120
128
 
121
129
  ```bash
@@ -172,6 +180,6 @@ assets live outside the package directory and are not included in the wheel.
172
180
 
173
181
  ## Release Status
174
182
 
175
- Deepy is preparing its first public `0.1.2` release. The current release path is
183
+ Deepy is preparing its first public `0.1.3` release. The current release path is
176
184
  GitHub + PyPI. Standalone binaries and npm wrappers can be added later, but the
177
185
  primary distribution is the Python CLI.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deepy-cli"
3
- version = "0.1.2"
3
+ version = "0.1.3"
4
4
  description = "Deepy - Vibe coding for DeepSeek models in your terminal"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.1.3"
4
4
 
5
5
 
6
6
  def main() -> None:
@@ -11,7 +11,7 @@ import tomli_w
11
11
 
12
12
  from . import __version__
13
13
  from .config import Settings, load_settings, settings_to_toml_dict
14
- from .config.settings import DEFAULT_BASE_URL, DEFAULT_MODEL
14
+ from .config.settings import DEFAULT_BASE_URL, DEFAULT_MODEL, DEFAULT_WEB_SEARCH_SEARXNG_URL
15
15
  from .errors import format_error_display
16
16
  from .llm.provider import build_provider_bundle
17
17
  from .llm.runner import DEFAULT_MAX_TURNS, run_prompt_once
@@ -159,9 +159,7 @@ def _write_config(config_path: Path, *, api_key: str, model: str, base_url: str)
159
159
  },
160
160
  "tools": {
161
161
  "web_search": {
162
- "command": "",
163
- "api_url": "",
164
- "machine_id": "",
162
+ "searxng_url": DEFAULT_WEB_SEARCH_SEARXNG_URL,
165
163
  },
166
164
  },
167
165
  }
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from .settings import (
4
4
  ContextConfig,
5
+ DEFAULT_WEB_SEARCH_SEARXNG_URL,
5
6
  ModelConfig,
6
7
  Settings,
7
8
  default_config_path,
@@ -12,6 +13,7 @@ from .settings import (
12
13
 
13
14
  __all__ = [
14
15
  "ContextConfig",
16
+ "DEFAULT_WEB_SEARCH_SEARXNG_URL",
15
17
  "ModelConfig",
16
18
  "Settings",
17
19
  "default_config_path",
@@ -11,6 +11,7 @@ DEFAULT_BASE_URL = "https://api.deepseek.com"
11
11
  DEFAULT_CONTEXT_WINDOW_TOKENS = 1_048_576
12
12
  DEFAULT_COMPACT_TRIGGER_RATIO = 0.8
13
13
  DEFAULT_COMPACT_PROMPT_TOKEN_THRESHOLD = 838_861
14
+ DEFAULT_WEB_SEARCH_SEARXNG_URL = "https://s.kirineko.tech/"
14
15
  REASONING_EFFORTS = {"high", "max"}
15
16
 
16
17
 
@@ -142,16 +143,12 @@ class NotifyConfig:
142
143
 
143
144
  @dataclass(frozen=True)
144
145
  class WebSearchToolConfig:
145
- command: str | None = None
146
- api_url: str | None = None
147
- machine_id: str | None = None
146
+ searxng_url: str | None = DEFAULT_WEB_SEARCH_SEARXNG_URL
148
147
 
149
148
  @classmethod
150
149
  def from_mapping(cls, raw: Mapping[str, Any]) -> Self:
151
150
  return cls(
152
- command=_as_str(raw.get("command")) or None,
153
- api_url=_as_str(raw.get("api_url")) or None,
154
- machine_id=_as_str(raw.get("machine_id")) or None,
151
+ searxng_url=_as_str(raw.get("searxng_url"), DEFAULT_WEB_SEARCH_SEARXNG_URL),
155
152
  )
156
153
 
157
154
 
@@ -0,0 +1,15 @@
1
+ ## WebSearch
2
+
3
+ Search when current or external information is required.
4
+
5
+ Args: `query`.
6
+
7
+ Uses `tools.web_search.searxng_url` when configured, otherwise uses Deepy's
8
+ default SearXNG endpoint. Requests include browser-like headers so private
9
+ SearXNG instances with limiter enabled are less likely to reject the request as
10
+ bot traffic. If SearXNG cannot be reached or returns no parseable results, falls
11
+ back to Deepy's built-in DuckDuckGo HTML search implementation.
12
+
13
+ Keep searches targeted. After several successful searches, stop searching and
14
+ synthesize from the gathered sources. Use `WebFetch` for exact URLs that need
15
+ deeper reading instead of continuing broad search queries.
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
4
+ import contextlib
3
5
  import time
4
6
  from collections.abc import Callable
5
7
  from dataclasses import dataclass, field
@@ -83,6 +85,7 @@ async def run_prompt_once(
83
85
  waiting_for_user = False
84
86
  pending_questions: list[dict[str, Any]] = []
85
87
  usage = TokenUsage()
88
+ interrupt_task: asyncio.Task[bool] | None = None
86
89
  try:
87
90
  result = Runner.run_streamed(
88
91
  agent,
@@ -91,6 +94,14 @@ async def run_prompt_once(
91
94
  run_config=run_config,
92
95
  session=session,
93
96
  )
97
+ if should_interrupt is not None:
98
+ interrupt_task = asyncio.create_task(
99
+ _watch_stream_interrupt(
100
+ result,
101
+ should_interrupt=should_interrupt,
102
+ cancel_mode=cancel_mode,
103
+ )
104
+ )
94
105
  async for event in result.stream_events():
95
106
  if should_interrupt is not None and should_interrupt():
96
107
  _cancel_stream_result(result, mode=cancel_mode)
@@ -120,6 +131,7 @@ async def run_prompt_once(
120
131
  interrupted = True
121
132
  break
122
133
  except MaxTurnsExceeded:
134
+ interrupted = interrupted or await _finish_interrupt_task(interrupt_task)
123
135
  result_usage = usage_from_run_result(result)
124
136
  if result_usage.known:
125
137
  usage = result_usage
@@ -134,6 +146,7 @@ async def run_prompt_once(
134
146
  duration_ms=duration_ms,
135
147
  )
136
148
  except APIStatusError as exc:
149
+ interrupted = interrupted or await _finish_interrupt_task(interrupt_task)
137
150
  log_api_error(
138
151
  {
139
152
  "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
@@ -164,6 +177,7 @@ async def run_prompt_once(
164
177
  duration_ms=duration_ms,
165
178
  )
166
179
  except Exception as exc:
180
+ await _finish_interrupt_task(interrupt_task)
167
181
  log_api_error(
168
182
  {
169
183
  "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
@@ -181,6 +195,8 @@ async def run_prompt_once(
181
195
  )
182
196
  raise
183
197
 
198
+ interrupted = interrupted or await _finish_interrupt_task(interrupt_task)
199
+
184
200
  final_output = getattr(result, "final_output", None)
185
201
  output = final_output if isinstance(final_output, str) else "".join(chunks)
186
202
  result_usage = usage_from_run_result(result)
@@ -386,6 +402,31 @@ def _cancel_stream_result(
386
402
  cancel()
387
403
 
388
404
 
405
+ async def _watch_stream_interrupt(
406
+ result: Any,
407
+ *,
408
+ should_interrupt: Callable[[], bool],
409
+ cancel_mode: Literal["immediate", "after_turn"],
410
+ ) -> bool:
411
+ while not bool(getattr(result, "is_complete", False)):
412
+ if should_interrupt():
413
+ _cancel_stream_result(result, mode=cancel_mode)
414
+ return True
415
+ await asyncio.sleep(0.05)
416
+ return False
417
+
418
+
419
+ async def _finish_interrupt_task(task: asyncio.Task[bool] | None) -> bool:
420
+ if task is None:
421
+ return False
422
+ if task.done():
423
+ return task.result()
424
+ task.cancel()
425
+ with contextlib.suppress(asyncio.CancelledError):
426
+ await task
427
+ return False
428
+
429
+
389
430
  def _pending_questions_from_tool_output(output: str) -> list[dict[str, Any]]:
390
431
  if not output.strip():
391
432
  return []
@@ -84,7 +84,11 @@ def build_function_tools(runtime: ToolRuntime) -> list[object]:
84
84
  ),
85
85
  FunctionTool(
86
86
  name="WebSearch",
87
- description="Perform web searching using a natural language query.",
87
+ description=(
88
+ "Perform web searching using a natural language query. Use a small number of "
89
+ "targeted searches, then stop and synthesize once enough sources are available; "
90
+ "prefer WebFetch for exact URLs."
91
+ ),
88
92
  params_json_schema=WEB_SEARCH_SCHEMA,
89
93
  on_invoke_tool=invoke_web_search,
90
94
  strict_json_schema=False,
@@ -1,24 +1,25 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import base64
4
+ import gzip
4
5
  import math
5
6
  import os
6
7
  import re
7
8
  import signal
8
- import shlex
9
9
  import subprocess
10
10
  import tempfile
11
11
  import time
12
12
  import urllib.parse
13
13
  import urllib.request
14
14
  import uuid
15
+ import zlib
15
16
  from dataclasses import dataclass, field
16
17
  from difflib import unified_diff
17
18
  from fnmatch import fnmatch
18
19
  from html.parser import HTMLParser
19
20
  from pathlib import Path
20
21
 
21
- from deepy.config import Settings
22
+ from deepy.config import DEFAULT_WEB_SEARCH_SEARXNG_URL, Settings, mask_secret
22
23
  from deepy.utils import json as json_utils
23
24
 
24
25
  from .file_state import FileSnippet, FileState
@@ -36,6 +37,20 @@ MAX_WEB_FETCH_BYTES = 2 * 1024 * 1024
36
37
  MAX_WEB_FETCH_OUTPUT_CHARS = 30_000
37
38
  DEFAULT_WEB_SEARCH_URL = "https://html.duckduckgo.com/html/"
38
39
  DEFAULT_WEB_SEARCH_RESULTS = 8
40
+ MAX_WEB_SEARCH_CALLS_PER_TURN = 8
41
+ WEB_SEARCH_BROWSER_HEADERS = {
42
+ "User-Agent": (
43
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
44
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
45
+ "Chrome/124.0.0.0 Safari/537.36"
46
+ ),
47
+ "Accept": "application/json,text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
48
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
49
+ "Accept-Encoding": "gzip, deflate",
50
+ "Sec-Fetch-Site": "none",
51
+ "Sec-Fetch-Mode": "navigate",
52
+ "Sec-Fetch-Dest": "document",
53
+ }
39
54
  PDF_LARGE_PAGE_THRESHOLD = 10
40
55
  PDF_MAX_PAGE_RANGE = 20
41
56
  MAX_CANDIDATE_COUNT = 5
@@ -164,6 +179,26 @@ class WebSearchResult:
164
179
  snippet: str = ""
165
180
 
166
181
 
182
+ @dataclass(frozen=True)
183
+ class WebSearchProviderFailure:
184
+ provider: str
185
+ error: str
186
+ search_url: str | None = None
187
+
188
+ def metadata(self) -> dict[str, str]:
189
+ payload = {"provider": self.provider, "error": self.error}
190
+ if self.search_url:
191
+ payload["searchUrl"] = _mask_url_secrets(self.search_url)
192
+ return payload
193
+
194
+
195
+ @dataclass(frozen=True)
196
+ class WebSearchProviderResult:
197
+ provider: str
198
+ search_url: str
199
+ results: list[WebSearchResult]
200
+
201
+
167
202
  def _find_occurrences(text: str, needle: str, scope: tuple[int, int]) -> list[MatchOccurrence]:
168
203
  matches: list[MatchOccurrence] = []
169
204
  scoped_text = text[scope[0] : scope[1]]
@@ -773,6 +808,86 @@ def _format_search_results(query: str, results: list[WebSearchResult]) -> str:
773
808
  return "\n".join(lines).strip()
774
809
 
775
810
 
811
+ def _parse_searxng_results(body: str) -> list[WebSearchResult]:
812
+ payload = json_utils.loads(body)
813
+ if not isinstance(payload, dict):
814
+ raise ValueError("SearXNG response must be a JSON object.")
815
+ raw_results = payload.get("results")
816
+ if not isinstance(raw_results, list):
817
+ raise ValueError("SearXNG response is missing a results array.")
818
+ results: list[WebSearchResult] = []
819
+ seen_urls: set[str] = set()
820
+ for item in raw_results:
821
+ if not isinstance(item, dict):
822
+ continue
823
+ title = item.get("title")
824
+ url = item.get("url")
825
+ if not isinstance(title, str) or not title.strip():
826
+ continue
827
+ if not isinstance(url, str) or not url.strip() or url in seen_urls:
828
+ continue
829
+ content = item.get("content")
830
+ snippet = content if isinstance(content, str) else ""
831
+ seen_urls.add(url)
832
+ results.append(WebSearchResult(title=" ".join(title.split()), url=url, snippet=snippet.strip()))
833
+ return results
834
+
835
+
836
+ def _build_searxng_search_url(base_url: str, query: str) -> str:
837
+ stripped = base_url.strip()
838
+ parsed = urllib.parse.urlparse(stripped)
839
+ if not parsed.scheme or not parsed.netloc:
840
+ raise ValueError("SearXNG URL must be a complete http or https URL.")
841
+ if parsed.scheme not in {"http", "https"}:
842
+ raise ValueError("SearXNG URL must use http or https.")
843
+ path = parsed.path.rstrip("/")
844
+ endpoint_path = parsed.path if path.endswith("/search") else f"{path}/search"
845
+ parts = parsed._replace(path=endpoint_path or "/search")
846
+ query_params = urllib.parse.parse_qsl(parts.query, keep_blank_values=True)
847
+ query_params.extend([("q", query), ("format", "json")])
848
+ return urllib.parse.urlunparse(parts._replace(query=urllib.parse.urlencode(query_params)))
849
+
850
+
851
+ def _decode_http_body(body: bytes, *, encoding: str | None, charset: str = "utf-8") -> str:
852
+ normalized_encoding = (encoding or "").split(";", 1)[0].strip().lower()
853
+ if normalized_encoding == "gzip":
854
+ body = gzip.decompress(body)
855
+ elif normalized_encoding == "deflate":
856
+ try:
857
+ body = zlib.decompress(body)
858
+ except zlib.error:
859
+ body = zlib.decompress(body, -zlib.MAX_WBITS)
860
+ elif normalized_encoding not in {"", "identity"}:
861
+ raise ValueError(f"Unsupported content encoding: {encoding}")
862
+ return body.decode(charset, errors="replace")
863
+
864
+
865
+ def _response_header(response: object, name: str) -> str | None:
866
+ headers = getattr(response, "headers", None)
867
+ if headers is None:
868
+ return None
869
+ getter = getattr(headers, "get", None)
870
+ if not callable(getter):
871
+ return None
872
+ value = getter(name)
873
+ return value if isinstance(value, str) else None
874
+
875
+
876
+ def _mask_url_secrets(url: str) -> str:
877
+ parsed = urllib.parse.urlparse(url)
878
+ query_params = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
879
+ sensitive_keys = {"api_key", "apikey", "key", "token", "access_token", "auth", "authorization"}
880
+ masked = [
881
+ (key, mask_secret(value) if key.lower() in sensitive_keys else value)
882
+ for key, value in query_params
883
+ ]
884
+ return urllib.parse.urlunparse(parsed._replace(query=urllib.parse.urlencode(masked)))
885
+
886
+
887
+ def _format_provider_failures(failures: list[WebSearchProviderFailure]) -> str:
888
+ return "; ".join(f"{failure.provider}: {failure.error}" for failure in failures)
889
+
890
+
776
891
  class _ReadableHtmlParser(HTMLParser):
777
892
  BLOCK_TAGS = {
778
893
  "address",
@@ -928,6 +1043,7 @@ class ToolRuntime:
928
1043
  settings: Settings
929
1044
  file_state: FileState = field(default_factory=FileState)
930
1045
  running_processes: dict[str, dict[str, str]] = field(default_factory=dict)
1046
+ web_search_calls: int = 0
931
1047
 
932
1048
  def read(
933
1049
  self,
@@ -1317,12 +1433,21 @@ class ToolRuntime:
1317
1433
  name = "WebSearch"
1318
1434
  if not query.strip():
1319
1435
  return ToolResult.error_result(name, 'Missing required "query" string.').to_json()
1320
- command = self.settings.tools.web_search.command
1321
- if command:
1322
- return self._web_search_command(query, command)
1323
- api_url = self.settings.tools.web_search.api_url
1324
- if api_url:
1325
- return self._web_search_api(query, api_url)
1436
+ self.web_search_calls += 1
1437
+ if self.web_search_calls > MAX_WEB_SEARCH_CALLS_PER_TURN:
1438
+ return ToolResult.error_result(
1439
+ name,
1440
+ (
1441
+ f"WebSearch call limit reached for this turn "
1442
+ f"({MAX_WEB_SEARCH_CALLS_PER_TURN}). Stop searching and answer from the "
1443
+ "results already gathered, or use WebFetch only for a specific URL that is "
1444
+ "essential."
1445
+ ),
1446
+ metadata={
1447
+ "callLimit": MAX_WEB_SEARCH_CALLS_PER_TURN,
1448
+ "callCount": self.web_search_calls,
1449
+ },
1450
+ ).to_json()
1326
1451
  return self._web_search_builtin(query)
1327
1452
 
1328
1453
  def web_fetch(self, url: str) -> str:
@@ -1398,196 +1523,147 @@ class ToolRuntime:
1398
1523
  },
1399
1524
  ).to_json()
1400
1525
 
1401
- def _web_search_command(self, query: str, command: str) -> str:
1402
- name = "WebSearch"
1403
- prepared = _prepare_web_search_query(query)
1404
- activity_label = _format_web_search_activity_label(query)
1405
- process: subprocess.Popen[str] | None = None
1406
- try:
1407
- process = subprocess.Popen(
1408
- f"{command} {shlex.quote(prepared.resolved_query)}",
1409
- shell=True,
1410
- cwd=self.cwd,
1411
- text=True,
1412
- stdout=subprocess.PIPE,
1413
- stderr=subprocess.PIPE,
1414
- stdin=subprocess.DEVNULL,
1415
- executable="/bin/zsh",
1416
- )
1417
- process_id = str(process.pid)
1418
- self.running_processes[process_id] = {
1419
- "startTime": _now_iso(),
1420
- "command": activity_label,
1421
- }
1422
- stdout, stderr = process.communicate(timeout=60)
1423
- except subprocess.TimeoutExpired:
1424
- if process is not None:
1425
- _terminate_process(process)
1426
- stdout, stderr = process.communicate()
1427
- self.running_processes.pop(str(process.pid), None)
1428
- output, output_truncated = _truncate_output((stdout or "") + (stderr or ""))
1429
- return ToolResult.error_result(
1430
- name,
1431
- "WebSearch command timed out after 60000ms.",
1432
- output=output,
1433
- metadata={
1434
- **prepared.metadata(),
1435
- "activityLabel": activity_label,
1436
- "outputTruncated": output_truncated,
1437
- "interrupted": True,
1438
- },
1439
- ).to_json()
1440
- finally:
1441
- if process is not None:
1442
- self.running_processes.pop(str(process.pid), None)
1443
- output = (stdout or "") + (stderr or "")
1444
- output, output_truncated = _truncate_output(output)
1445
- if process.returncode != 0:
1446
- return ToolResult.error_result(
1447
- name,
1448
- f"WebSearch command exited with code {process.returncode}.",
1449
- output=output,
1450
- metadata={
1451
- **prepared.metadata(),
1452
- "exitCode": process.returncode,
1453
- "activityLabel": activity_label,
1454
- "outputTruncated": output_truncated,
1455
- },
1456
- ).to_json()
1457
- return ToolResult.ok_result(
1458
- name,
1459
- output,
1460
- metadata={
1461
- **prepared.metadata(),
1462
- "exitCode": process.returncode,
1463
- "activityLabel": activity_label,
1464
- "outputTruncated": output_truncated,
1465
- },
1466
- ).to_json()
1467
-
1468
1526
  def _web_search_builtin(self, query: str) -> str:
1469
1527
  name = "WebSearch"
1470
1528
  prepared, prepare_error = _prepare_web_search_query_with_llm(query, self.settings)
1471
- search_url = (
1472
- DEFAULT_WEB_SEARCH_URL
1473
- + "?"
1474
- + urllib.parse.urlencode({"q": prepared.resolved_query}, doseq=False)
1475
- )
1476
1529
  activity_label = _format_web_search_activity_label(prepared.resolved_query)
1477
1530
  activity_id = f"web-search-{uuid.uuid4().hex}"
1478
1531
  self.running_processes[activity_id] = {
1479
1532
  "startTime": _now_iso(),
1480
1533
  "command": activity_label,
1481
1534
  }
1482
- request = urllib.request.Request(
1483
- search_url,
1484
- headers={
1485
- "User-Agent": (
1486
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
1487
- "AppleWebKit/537.36 (KHTML, like Gecko) Deepy/0.1"
1488
- )
1489
- },
1490
- method="GET",
1491
- )
1535
+ failures: list[WebSearchProviderFailure] = []
1536
+ query_metadata = {
1537
+ **prepared.metadata(),
1538
+ "activityLabel": activity_label,
1539
+ **({"queryPreparationWarning": prepare_error} if prepare_error else {}),
1540
+ }
1492
1541
  try:
1493
- with urllib.request.urlopen(request, timeout=30) as response:
1494
- body = response.read().decode("utf-8", errors="replace")
1495
- except Exception as exc:
1542
+ searxng_url = self.settings.tools.web_search.searxng_url or DEFAULT_WEB_SEARCH_SEARXNG_URL
1543
+ result, failure = self._try_searxng_search(prepared.resolved_query, searxng_url)
1544
+ if result is not None:
1545
+ return ToolResult.ok_result(
1546
+ name,
1547
+ _format_search_results(prepared.resolved_query, result.results),
1548
+ metadata={
1549
+ **query_metadata,
1550
+ "backend": result.provider,
1551
+ "provider": result.provider,
1552
+ "searchUrl": _mask_url_secrets(result.search_url),
1553
+ "providerAttempts": [
1554
+ {**item.metadata(), "ok": False} for item in failures
1555
+ ]
1556
+ + [{"provider": result.provider, "ok": True}],
1557
+ "resultCount": min(len(result.results), DEFAULT_WEB_SEARCH_RESULTS),
1558
+ },
1559
+ ).to_json()
1560
+ if failure is not None:
1561
+ failures.append(failure)
1562
+
1563
+ result, failure = self._try_duckduckgo_search(prepared.resolved_query)
1564
+ if result is not None:
1565
+ return ToolResult.ok_result(
1566
+ name,
1567
+ _format_search_results(prepared.resolved_query, result.results),
1568
+ metadata={
1569
+ **query_metadata,
1570
+ "backend": result.provider,
1571
+ "provider": result.provider,
1572
+ "searchUrl": _mask_url_secrets(result.search_url),
1573
+ "providerAttempts": [
1574
+ {**item.metadata(), "ok": False} for item in failures
1575
+ ]
1576
+ + [{"provider": result.provider, "ok": True}],
1577
+ "resultCount": min(len(result.results), DEFAULT_WEB_SEARCH_RESULTS),
1578
+ },
1579
+ ).to_json()
1580
+ if failure is not None:
1581
+ failures.append(failure)
1582
+
1496
1583
  return ToolResult.error_result(
1497
1584
  name,
1498
- f"WebSearch request failed: {exc}",
1585
+ "WebSearch failed: " + _format_provider_failures(failures),
1499
1586
  metadata={
1500
- **prepared.metadata(),
1501
- "backend": "duckduckgo_html",
1502
- "searchUrl": search_url,
1503
- "activityLabel": activity_label,
1504
- **({"queryPreparationWarning": prepare_error} if prepare_error else {}),
1587
+ **query_metadata,
1588
+ "backend": "provider_chain",
1589
+ "providerAttempts": [
1590
+ {**item.metadata(), "ok": False} for item in failures
1591
+ ],
1505
1592
  },
1506
1593
  ).to_json()
1507
1594
  finally:
1508
1595
  self.running_processes.pop(activity_id, None)
1509
1596
 
1510
- results = _parse_search_results(body)
1511
- if not results:
1512
- return ToolResult.error_result(
1513
- name,
1514
- "WebSearch returned no parseable results.",
1515
- metadata={
1516
- **prepared.metadata(),
1517
- "backend": "duckduckgo_html",
1518
- "searchUrl": search_url,
1519
- "activityLabel": activity_label,
1520
- **({"queryPreparationWarning": prepare_error} if prepare_error else {}),
1521
- },
1522
- ).to_json()
1523
- return ToolResult.ok_result(
1524
- name,
1525
- _format_search_results(prepared.resolved_query, results),
1526
- metadata={
1527
- **prepared.metadata(),
1528
- "backend": "duckduckgo_html",
1529
- "searchUrl": search_url,
1530
- "activityLabel": activity_label,
1531
- "resultCount": min(len(results), DEFAULT_WEB_SEARCH_RESULTS),
1532
- **({"queryPreparationWarning": prepare_error} if prepare_error else {}),
1533
- },
1534
- ).to_json()
1535
-
1536
- def _web_search_api(self, query: str, api_url: str) -> str:
1537
- name = "WebSearch"
1538
- prepared, prepare_error = _prepare_web_search_query_with_llm(query, self.settings)
1539
- if prepare_error is not None:
1540
- return ToolResult.error_result(
1541
- name,
1542
- f"WebSearch custom API mode failed: {prepare_error}",
1543
- metadata={"query": query, "apiUrl": api_url},
1544
- ).to_json()
1545
- machine_id = self.settings.tools.web_search.machine_id
1546
- if not machine_id:
1547
- return ToolResult.error_result(
1548
- name,
1549
- "WebSearch custom API mode requires machine_id in the TOML tools.web_search config.",
1550
- metadata={**prepared.metadata(), "apiUrl": api_url},
1551
- ).to_json()
1552
- body = json_utils.dumps({"query": prepared.resolved_query}).encode("utf-8")
1597
+ def _try_duckduckgo_search(
1598
+ self,
1599
+ query: str,
1600
+ ) -> tuple[WebSearchProviderResult | None, WebSearchProviderFailure | None]:
1601
+ provider = "duckduckgo_html"
1602
+ search_url = DEFAULT_WEB_SEARCH_URL + "?" + urllib.parse.urlencode({"q": query}, doseq=False)
1553
1603
  request = urllib.request.Request(
1554
- api_url,
1555
- data=body,
1604
+ search_url,
1556
1605
  headers={
1557
- "Content-Type": "application/json",
1558
- "Token": machine_id,
1606
+ **WEB_SEARCH_BROWSER_HEADERS,
1607
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
1559
1608
  },
1560
- method="POST",
1609
+ method="GET",
1561
1610
  )
1562
1611
  try:
1563
1612
  with urllib.request.urlopen(request, timeout=30) as response:
1564
- body = response.read().decode("utf-8", errors="replace")
1613
+ body = _decode_http_body(
1614
+ response.read(),
1615
+ encoding=_response_header(response, "Content-Encoding"),
1616
+ )
1565
1617
  except Exception as exc:
1566
- return ToolResult.error_result(
1567
- name,
1568
- f"WebSearch API request failed: {exc}",
1569
- metadata={**prepared.metadata(), "apiUrl": api_url},
1570
- ).to_json()
1571
- output = body.strip()
1618
+ return None, WebSearchProviderFailure(
1619
+ provider=provider,
1620
+ error=f"request failed: {exc}",
1621
+ search_url=search_url,
1622
+ )
1623
+ results = _parse_search_results(body)
1624
+ if not results:
1625
+ return None, WebSearchProviderFailure(
1626
+ provider=provider,
1627
+ error="no parseable results",
1628
+ search_url=search_url,
1629
+ )
1630
+ return WebSearchProviderResult(provider=provider, search_url=search_url, results=results), None
1631
+
1632
+ def _try_searxng_search(
1633
+ self,
1634
+ query: str,
1635
+ base_url: str,
1636
+ ) -> tuple[WebSearchProviderResult | None, WebSearchProviderFailure | None]:
1637
+ provider = "searxng_json"
1572
1638
  try:
1573
- payload = json_utils.loads(body)
1574
- except json_utils.JSONDecodeError:
1575
- payload = None
1576
- if isinstance(payload, dict):
1577
- result = payload.get("result")
1578
- if isinstance(result, str) and result.strip():
1579
- output = result.strip()
1580
- if not output:
1581
- return ToolResult.error_result(
1582
- name,
1583
- "WebSearch custom API mode failed: The web search response was empty.",
1584
- metadata={**prepared.metadata(), "apiUrl": api_url},
1585
- ).to_json()
1586
- return ToolResult.ok_result(
1587
- name,
1588
- output,
1589
- metadata={**prepared.metadata(), "apiUrl": api_url, "usedMachineId": bool(machine_id)},
1590
- ).to_json()
1639
+ search_url = _build_searxng_search_url(base_url, query)
1640
+ except ValueError as exc:
1641
+ return None, WebSearchProviderFailure(provider=provider, error=str(exc))
1642
+ request = urllib.request.Request(
1643
+ search_url,
1644
+ headers=WEB_SEARCH_BROWSER_HEADERS,
1645
+ method="GET",
1646
+ )
1647
+ try:
1648
+ with urllib.request.urlopen(request, timeout=30) as response:
1649
+ body = _decode_http_body(
1650
+ response.read(),
1651
+ encoding=_response_header(response, "Content-Encoding"),
1652
+ )
1653
+ results = _parse_searxng_results(body)
1654
+ except Exception as exc:
1655
+ return None, WebSearchProviderFailure(
1656
+ provider=provider,
1657
+ error=f"request failed: {exc}",
1658
+ search_url=search_url,
1659
+ )
1660
+ if not results:
1661
+ return None, WebSearchProviderFailure(
1662
+ provider=provider,
1663
+ error="no parseable results",
1664
+ search_url=search_url,
1665
+ )
1666
+ return WebSearchProviderResult(provider=provider, search_url=search_url, results=results), None
1591
1667
 
1592
1668
 
1593
1669
  def _unified_diff(old: str, new: str, *, path: str) -> str:
@@ -1,8 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import contextlib
5
+ import os
6
+ import select
7
+ import sys
8
+ import termios
4
9
  import threading
5
10
  import time
11
+ import tty
6
12
  from collections.abc import Awaitable, Callable
7
13
  from dataclasses import dataclass
8
14
  from pathlib import Path
@@ -220,10 +226,19 @@ def _run_once_with_status(
220
226
  **kwargs: object,
221
227
  ) -> RunSummary:
222
228
  original_emit_event = kwargs.pop("emit_event", None)
229
+ original_should_interrupt = kwargs.pop("should_interrupt", None)
223
230
  project_root = kwargs.get("project_root")
224
231
  project_root_text = str(project_root) if project_root is not None else None
225
232
  renderer: TerminalStreamRenderer | None = None
226
233
  started_at = time.monotonic()
234
+ interrupt_requested = threading.Event()
235
+
236
+ def should_interrupt() -> bool:
237
+ if interrupt_requested.is_set():
238
+ return True
239
+ return bool(callable(original_should_interrupt) and original_should_interrupt())
240
+
241
+ kwargs["should_interrupt"] = should_interrupt
227
242
 
228
243
  with console.status(_working_status_text(started_at), spinner="dots") as status:
229
244
  renderer = TerminalStreamRenderer(
@@ -241,12 +256,13 @@ def _run_once_with_status(
241
256
  status_thread.start()
242
257
 
243
258
  try:
244
- def emit_event(event: DeepyStreamEvent) -> None:
245
- renderer(event)
246
- if callable(original_emit_event):
247
- original_emit_event(event)
259
+ with _esc_interrupt_watcher(interrupt_requested):
260
+ def emit_event(event: DeepyStreamEvent) -> None:
261
+ renderer(event)
262
+ if callable(original_emit_event):
263
+ original_emit_event(event)
248
264
 
249
- summary = asyncio.run(run_once(prompt, **kwargs, emit_event=emit_event))
265
+ summary = asyncio.run(run_once(prompt, **kwargs, emit_event=emit_event))
250
266
  finally:
251
267
  stop_status_refresh.set()
252
268
  status_thread.join(timeout=0.2)
@@ -255,6 +271,58 @@ def _run_once_with_status(
255
271
  return summary
256
272
 
257
273
 
274
+ @contextlib.contextmanager
275
+ def _esc_interrupt_watcher(interrupt_requested: threading.Event):
276
+ if not sys.stdin.isatty() and not Path("/dev/tty").exists():
277
+ yield
278
+ return
279
+
280
+ stop_event = threading.Event()
281
+ thread = threading.Thread(
282
+ target=_watch_esc_keypress,
283
+ args=(interrupt_requested, stop_event),
284
+ daemon=True,
285
+ )
286
+ thread.start()
287
+ try:
288
+ yield
289
+ finally:
290
+ stop_event.set()
291
+ thread.join(timeout=0.2)
292
+
293
+
294
+ def _watch_esc_keypress(
295
+ interrupt_requested: threading.Event,
296
+ stop_event: threading.Event,
297
+ ) -> None:
298
+ fd: int | None = None
299
+ old_attrs: list[Any] | None = None
300
+ try:
301
+ fd = os.open("/dev/tty", os.O_RDONLY | os.O_NONBLOCK)
302
+ old_attrs = termios.tcgetattr(fd)
303
+ tty.setcbreak(fd)
304
+ while not stop_event.is_set() and not interrupt_requested.is_set():
305
+ readable, _, _ = select.select([fd], [], [], 0.05)
306
+ if not readable:
307
+ continue
308
+ try:
309
+ data = os.read(fd, 32)
310
+ except BlockingIOError:
311
+ continue
312
+ if b"\x1b" in data:
313
+ interrupt_requested.set()
314
+ return
315
+ except Exception:
316
+ return
317
+ finally:
318
+ if fd is not None:
319
+ if old_attrs is not None:
320
+ with contextlib.suppress(Exception):
321
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_attrs)
322
+ with contextlib.suppress(Exception):
323
+ os.close(fd)
324
+
325
+
258
326
  class TerminalStreamRenderer:
259
327
  def __init__(
260
328
  self,
@@ -1,9 +0,0 @@
1
- ## WebSearch
2
-
3
- Search when current or external information is required.
4
-
5
- Args: `query`.
6
-
7
- Uses the configured local command first, then configured API endpoint if present. If
8
- neither is configured, uses Deepy's built-in local web search implementation and
9
- returns parsed search result titles, URLs, and snippets.
File without changes
File without changes
File without changes
File without changes
File without changes