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.
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/PKG-INFO +10 -2
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/README.md +9 -1
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/pyproject.toml +1 -1
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/__init__.py +1 -1
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/cli.py +2 -4
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/config/__init__.py +2 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/config/settings.py +3 -6
- deepy_cli-0.1.3/src/deepy/data/tools/WebSearch.md +15 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/llm/runner.py +41 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/tools/agents.py +5 -1
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/tools/builtin.py +248 -172
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/terminal.py +73 -5
- deepy_cli-0.1.2/src/deepy/data/tools/WebSearch.md +0 -9
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/__main__.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/data/__init__.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/data/tools/AskUserQuestion.md +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/data/tools/WebFetch.md +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/data/tools/__init__.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/data/tools/bash.md +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/data/tools/edit.md +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/data/tools/modify.md +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/data/tools/read.md +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/data/tools/write.md +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/errors.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/llm/__init__.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/llm/agent.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/llm/context.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/llm/events.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/llm/model_capabilities.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/llm/provider.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/llm/replay.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/llm/thinking.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/prompts/__init__.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/prompts/compact.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/prompts/rules.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/prompts/runtime_context.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/prompts/system.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/prompts/tool_docs.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/sessions/__init__.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/sessions/jsonl.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/sessions/manager.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/skills.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/status.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/tools/__init__.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/tools/file_state.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/tools/result.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/tools/shell_utils.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/__init__.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/app.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/ask_user_question.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/exit_summary.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/loading_text.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/markdown.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/message_view.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/prompt_buffer.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/prompt_input.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/session_list.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/session_picker.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/slash_commands.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/styles.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/thinking_state.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/ui/welcome.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/update_check.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/usage.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/utils/__init__.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/utils/debug_logger.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/utils/error_logger.py +0 -0
- {deepy_cli-0.1.2 → deepy_cli-0.1.3}/src/deepy/utils/json.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
@@ -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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
1321
|
-
if
|
|
1322
|
-
return
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
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
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
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
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
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
|
-
|
|
1585
|
+
"WebSearch failed: " + _format_provider_failures(failures),
|
|
1499
1586
|
metadata={
|
|
1500
|
-
**
|
|
1501
|
-
"backend": "
|
|
1502
|
-
"
|
|
1503
|
-
|
|
1504
|
-
|
|
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
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
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
|
-
|
|
1555
|
-
data=body,
|
|
1604
|
+
search_url,
|
|
1556
1605
|
headers={
|
|
1557
|
-
|
|
1558
|
-
"
|
|
1606
|
+
**WEB_SEARCH_BROWSER_HEADERS,
|
|
1607
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
1559
1608
|
},
|
|
1560
|
-
method="
|
|
1609
|
+
method="GET",
|
|
1561
1610
|
)
|
|
1562
1611
|
try:
|
|
1563
1612
|
with urllib.request.urlopen(request, timeout=30) as response:
|
|
1564
|
-
body =
|
|
1613
|
+
body = _decode_http_body(
|
|
1614
|
+
response.read(),
|
|
1615
|
+
encoding=_response_header(response, "Content-Encoding"),
|
|
1616
|
+
)
|
|
1565
1617
|
except Exception as exc:
|
|
1566
|
-
return
|
|
1567
|
-
|
|
1568
|
-
f"
|
|
1569
|
-
|
|
1570
|
-
)
|
|
1571
|
-
|
|
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
|
-
|
|
1574
|
-
except
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
original_emit_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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
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
|