crabcode 0.1.6__py3-none-win_amd64.whl
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.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +268 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Discover and activate tool schemas without executing the discovered tools."""
|
|
2
|
+
|
|
3
|
+
from crabcode_core.types.tool import PermissionBehavior, PermissionResult, Tool, ToolContext, ToolResult
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ToolSearchTool(Tool):
|
|
7
|
+
name = "ToolSearch"
|
|
8
|
+
description = (
|
|
9
|
+
"Find and load tools by exact names, group, or keyword query. Their schemas "
|
|
10
|
+
"become available in your NEXT response, never in the same batch. "
|
|
11
|
+
"Use list=true to browse without loading; paginate with offset. "
|
|
12
|
+
"Loading does not execute tools or grant permissions."
|
|
13
|
+
)
|
|
14
|
+
is_read_only = True
|
|
15
|
+
input_schema = {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"properties": {
|
|
18
|
+
"names": {
|
|
19
|
+
"type": "array", "items": {"type": "string"},
|
|
20
|
+
"description": (
|
|
21
|
+
"Exact registered tool names from the directory's names field; "
|
|
22
|
+
"do not add a group prefix."
|
|
23
|
+
),
|
|
24
|
+
},
|
|
25
|
+
"group": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"description": "Tool category to search or load. This is not a tool-name namespace.",
|
|
28
|
+
},
|
|
29
|
+
"query": {"type": "string"},
|
|
30
|
+
"list": {"type": "boolean", "default": False},
|
|
31
|
+
"offset": {"type": "integer", "minimum": 0, "default": 0},
|
|
32
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": 30, "default": 5},
|
|
33
|
+
},
|
|
34
|
+
"additionalProperties": False,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async def check_permissions(self, tool_input, context):
|
|
38
|
+
return PermissionResult(behavior=PermissionBehavior.ALLOW)
|
|
39
|
+
|
|
40
|
+
async def validate_input(self, tool_input):
|
|
41
|
+
names = tool_input.get("names", [])
|
|
42
|
+
if not isinstance(names, list) or any(not isinstance(name, str) for name in names):
|
|
43
|
+
return "names must be an array of tool names"
|
|
44
|
+
if any(not isinstance(tool_input.get(key, ""), str) for key in ("query", "group")):
|
|
45
|
+
return "query and group must be strings"
|
|
46
|
+
if not isinstance(tool_input.get("list", False), bool):
|
|
47
|
+
return "list must be a boolean"
|
|
48
|
+
offset, limit = tool_input.get("offset", 0), tool_input.get("limit", 5)
|
|
49
|
+
if type(offset) is not int or offset < 0:
|
|
50
|
+
return "offset must be a nonnegative integer"
|
|
51
|
+
if type(limit) is not int or not 1 <= limit <= 30:
|
|
52
|
+
return "limit must be an integer between 1 and 30"
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
async def call(self, tool_input: dict, context: ToolContext) -> ToolResult:
|
|
56
|
+
if context.tool_catalog is None:
|
|
57
|
+
return ToolResult(result_for_model="Tool discovery is unavailable in this context.", is_error=True)
|
|
58
|
+
result = context.tool_catalog.search(
|
|
59
|
+
names=tool_input.get("names", []), group=tool_input.get("group", ""),
|
|
60
|
+
query=tool_input.get("query", ""),
|
|
61
|
+
list_only=tool_input.get("list", False) or not any(tool_input.get(k) for k in ("names", "group", "query")),
|
|
62
|
+
offset=tool_input.get("offset", 0), limit=tool_input.get("limit", 5),
|
|
63
|
+
)
|
|
64
|
+
return ToolResult(result_for_model=result)
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"""WebSearchTool — search the web using Tavily, DuckDuckGo, or Bing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
import xml.etree.ElementTree as ET
|
|
9
|
+
from html.parser import HTMLParser
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import parse_qs, quote, unquote, urljoin, urlparse
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from crabcode_core.logging_utils import get_logger
|
|
16
|
+
from crabcode_core.types.tool import PermissionBehavior, PermissionResult, Tool, ToolContext, ToolResult
|
|
17
|
+
|
|
18
|
+
logger = get_logger(__name__)
|
|
19
|
+
|
|
20
|
+
_DEFAULT_TIMEOUT = 8
|
|
21
|
+
_DEFAULT_MAX_RESULTS = 5
|
|
22
|
+
_MAX_RESULTS = 10
|
|
23
|
+
_DDG_BASE_URL = "https://html.duckduckgo.com/html/"
|
|
24
|
+
_DDG_RESULT_BASE_URL = "https://duckduckgo.com"
|
|
25
|
+
_BING_HOME_URL = "https://www.bing.com/"
|
|
26
|
+
_BING_SEARCH_URL = "https://www.bing.com/search"
|
|
27
|
+
_TAVILY_URL = "https://api.tavily.com/search"
|
|
28
|
+
_BROWSER_UA = (
|
|
29
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
30
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
|
31
|
+
)
|
|
32
|
+
_DDG_BACKOFF_SECONDS = 10 * 60
|
|
33
|
+
_TAG_RE = re.compile(r"<[^>]+>")
|
|
34
|
+
_ddg_unavailable_until = 0.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DuckDuckGoUnavailable(RuntimeError):
|
|
38
|
+
"""DuckDuckGo did not return a results page."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _reset_ddg_backoff() -> None:
|
|
42
|
+
"""Clear the in-process DuckDuckGo backoff. Used by tests."""
|
|
43
|
+
global _ddg_unavailable_until
|
|
44
|
+
_ddg_unavailable_until = 0.0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _ddg_in_backoff() -> bool:
|
|
48
|
+
return time.monotonic() < _ddg_unavailable_until
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _note_ddg_unavailable() -> None:
|
|
52
|
+
global _ddg_unavailable_until
|
|
53
|
+
_ddg_unavailable_until = time.monotonic() + _DDG_BACKOFF_SECONDS
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _is_ddg_challenge(status_code: int, html: str) -> bool:
|
|
57
|
+
"""True when DuckDuckGo served a bot check instead of results.
|
|
58
|
+
|
|
59
|
+
HTTP 202 plus an anomaly page still passes ``raise_for_status`` and has no
|
|
60
|
+
result links, which used to be reported as an empty search.
|
|
61
|
+
"""
|
|
62
|
+
if status_code == 202:
|
|
63
|
+
return True
|
|
64
|
+
lowered = html.lower()
|
|
65
|
+
return "anomaly-modal" in lowered or "bots use duckduckgo" in lowered
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _local_name(tag: str) -> str:
|
|
69
|
+
return tag.rsplit("}", 1)[-1]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _plain(value: str) -> str:
|
|
73
|
+
return _clean_text(_TAG_RE.sub(" ", value))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _is_http_url(url: str) -> bool:
|
|
77
|
+
parsed = urlparse(url)
|
|
78
|
+
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _resolve_ddg_result_url(url: str) -> str:
|
|
82
|
+
raw = url.strip()
|
|
83
|
+
if not raw:
|
|
84
|
+
return ""
|
|
85
|
+
|
|
86
|
+
if raw.startswith("//"):
|
|
87
|
+
raw = f"https:{raw}"
|
|
88
|
+
elif raw.startswith("/"):
|
|
89
|
+
raw = urljoin(_DDG_RESULT_BASE_URL, raw)
|
|
90
|
+
|
|
91
|
+
parsed = urlparse(raw)
|
|
92
|
+
if parsed.netloc.endswith("duckduckgo.com") and parsed.path.startswith("/l/"):
|
|
93
|
+
uddg = parse_qs(parsed.query).get("uddg", [])
|
|
94
|
+
if uddg:
|
|
95
|
+
return unquote(uddg[0]).strip()
|
|
96
|
+
|
|
97
|
+
return raw
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _clean_text(value: str) -> str:
|
|
101
|
+
return " ".join(value.split())
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class _DuckDuckGoHTMLParser(HTMLParser):
|
|
105
|
+
"""Extract result links from DuckDuckGo's HTML results page."""
|
|
106
|
+
|
|
107
|
+
def __init__(self) -> None:
|
|
108
|
+
super().__init__()
|
|
109
|
+
self.results: list[dict[str, str]] = []
|
|
110
|
+
self._current: dict[str, str] | None = None
|
|
111
|
+
self._capture_title = False
|
|
112
|
+
self._capture_snippet = False
|
|
113
|
+
|
|
114
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
115
|
+
attrs_map = dict(attrs)
|
|
116
|
+
class_name = attrs_map.get("class", "")
|
|
117
|
+
if tag == "a" and "result__a" in class_name:
|
|
118
|
+
self._flush_current()
|
|
119
|
+
href = attrs_map.get("href") or ""
|
|
120
|
+
self._current = {"title": "", "url": href, "snippet": ""}
|
|
121
|
+
self._capture_title = True
|
|
122
|
+
return
|
|
123
|
+
if self._current and tag in {"a", "div", "span"} and "result__snippet" in class_name:
|
|
124
|
+
self._capture_snippet = True
|
|
125
|
+
|
|
126
|
+
def handle_endtag(self, tag: str) -> None:
|
|
127
|
+
if tag == "a" and self._capture_title:
|
|
128
|
+
self._capture_title = False
|
|
129
|
+
if tag in {"a", "div", "span"} and self._capture_snippet:
|
|
130
|
+
self._capture_snippet = False
|
|
131
|
+
if tag == "article" and self._current:
|
|
132
|
+
self._flush_current()
|
|
133
|
+
|
|
134
|
+
def handle_data(self, data: str) -> None:
|
|
135
|
+
if not self._current:
|
|
136
|
+
return
|
|
137
|
+
if self._capture_title:
|
|
138
|
+
self._current["title"] += data
|
|
139
|
+
elif self._capture_snippet:
|
|
140
|
+
self._current["snippet"] += data
|
|
141
|
+
|
|
142
|
+
def close(self) -> None:
|
|
143
|
+
super().close()
|
|
144
|
+
self._flush_current()
|
|
145
|
+
|
|
146
|
+
def _flush_current(self) -> None:
|
|
147
|
+
if not self._current:
|
|
148
|
+
return
|
|
149
|
+
title = _clean_text(self._current.get("title", ""))
|
|
150
|
+
url = _resolve_ddg_result_url(self._current.get("url", ""))
|
|
151
|
+
snippet = _clean_text(self._current.get("snippet", ""))
|
|
152
|
+
if title and _is_http_url(url):
|
|
153
|
+
self.results.append({"title": title, "url": url, "snippet": snippet})
|
|
154
|
+
self._current = None
|
|
155
|
+
self._capture_title = False
|
|
156
|
+
self._capture_snippet = False
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _parse_ddg_html(html: str) -> list[dict[str, str]]:
|
|
160
|
+
parser = _DuckDuckGoHTMLParser()
|
|
161
|
+
parser.feed(html)
|
|
162
|
+
parser.close()
|
|
163
|
+
return parser.results
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _parse_bing_rss(xml_text: str) -> list[dict[str, str]]:
|
|
167
|
+
stripped = xml_text.lstrip("\ufeff \t\r\n")
|
|
168
|
+
lowered = stripped[:200].lower()
|
|
169
|
+
if lowered.startswith("<!doctype html") or lowered.startswith("<html"):
|
|
170
|
+
raise RuntimeError("Bing returned an HTML page instead of search results")
|
|
171
|
+
try:
|
|
172
|
+
root = ET.fromstring(xml_text)
|
|
173
|
+
except ET.ParseError as exc:
|
|
174
|
+
raise RuntimeError("Bing returned a response that was not RSS") from exc
|
|
175
|
+
|
|
176
|
+
results: list[dict[str, str]] = []
|
|
177
|
+
for item in root.iter():
|
|
178
|
+
if _local_name(item.tag) != "item":
|
|
179
|
+
continue
|
|
180
|
+
fields = {_local_name(child.tag): child.text or "" for child in list(item)}
|
|
181
|
+
results.append(
|
|
182
|
+
{
|
|
183
|
+
"title": _plain(fields.get("title", "")),
|
|
184
|
+
"url": fields.get("link", "").strip(),
|
|
185
|
+
"snippet": _plain(fields.get("description", "")),
|
|
186
|
+
}
|
|
187
|
+
)
|
|
188
|
+
return results
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class WebSearchTool(Tool):
|
|
192
|
+
name = "WebSearch"
|
|
193
|
+
description = "Search the web for current external information using Tavily, DuckDuckGo, or Bing."
|
|
194
|
+
is_read_only = True
|
|
195
|
+
is_concurrency_safe = True
|
|
196
|
+
input_schema = {
|
|
197
|
+
"type": "object",
|
|
198
|
+
"properties": {
|
|
199
|
+
"query": {
|
|
200
|
+
"type": "string",
|
|
201
|
+
"description": "Search query to run on the public web.",
|
|
202
|
+
},
|
|
203
|
+
"num_results": {
|
|
204
|
+
"type": "integer",
|
|
205
|
+
"description": "Maximum number of results to return (default 5, max 10).",
|
|
206
|
+
"default": _DEFAULT_MAX_RESULTS,
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
"required": ["query"],
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
def __init__(self) -> None:
|
|
213
|
+
self._tool_config: dict[str, Any] = {}
|
|
214
|
+
self._provider = "auto"
|
|
215
|
+
self._timeout = _DEFAULT_TIMEOUT
|
|
216
|
+
self._max_results = _DEFAULT_MAX_RESULTS
|
|
217
|
+
self._api_key_env = "TAVILY_API_KEY"
|
|
218
|
+
self._api_key: str | None = None
|
|
219
|
+
|
|
220
|
+
async def setup(self, context: ToolContext) -> None:
|
|
221
|
+
await super().setup(context)
|
|
222
|
+
self._tool_config = dict(context.tool_config)
|
|
223
|
+
self._provider = str(self._tool_config.get("provider", "auto")).lower()
|
|
224
|
+
self._timeout = int(self._tool_config.get("timeout_seconds", _DEFAULT_TIMEOUT))
|
|
225
|
+
self._max_results = max(1, min(int(self._tool_config.get("max_results", _DEFAULT_MAX_RESULTS)), _MAX_RESULTS))
|
|
226
|
+
self._api_key_env = str(self._tool_config.get("api_key_env", "TAVILY_API_KEY"))
|
|
227
|
+
self._api_key = os.environ.get(self._api_key_env) or context.env.get(self._api_key_env)
|
|
228
|
+
|
|
229
|
+
if self._provider not in {"auto", "tavily", "ddg"}:
|
|
230
|
+
logger.warning("Unknown WebSearch provider %r; falling back to auto", self._provider)
|
|
231
|
+
self._provider = "auto"
|
|
232
|
+
|
|
233
|
+
if self._provider == "tavily" and not self._api_key:
|
|
234
|
+
logger.info("Disabling WebSearch: provider=tavily but %s is not configured", self._api_key_env)
|
|
235
|
+
self.is_enabled = False
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
if not await self._has_connectivity():
|
|
239
|
+
logger.info("Disabling WebSearch: outbound network probe failed")
|
|
240
|
+
self.is_enabled = False
|
|
241
|
+
|
|
242
|
+
async def get_prompt(self, **kwargs: Any) -> str:
|
|
243
|
+
return (
|
|
244
|
+
"Search the public web for current external information. "
|
|
245
|
+
"Use this instead of shell-based web access when you need recent facts, docs, or sources outside the repo. "
|
|
246
|
+
"Returns a compact list of result titles, URLs, and snippets. "
|
|
247
|
+
"This tool performs search only; it does not fetch and read full pages."
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
async def validate_input(self, tool_input: dict[str, Any]) -> str | None:
|
|
251
|
+
query = str(tool_input.get("query", "")).strip()
|
|
252
|
+
if not query:
|
|
253
|
+
return "query is required"
|
|
254
|
+
raw_num = tool_input.get("num_results", _DEFAULT_MAX_RESULTS)
|
|
255
|
+
try:
|
|
256
|
+
int(raw_num)
|
|
257
|
+
except (TypeError, ValueError):
|
|
258
|
+
return "num_results must be an integer"
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
async def check_permissions(
|
|
262
|
+
self,
|
|
263
|
+
tool_input: dict[str, Any],
|
|
264
|
+
context: ToolContext,
|
|
265
|
+
) -> PermissionResult:
|
|
266
|
+
return PermissionResult(
|
|
267
|
+
behavior=PermissionBehavior.ASK,
|
|
268
|
+
reason="WebSearch requires confirmation before making a network request",
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
async def call(
|
|
272
|
+
self,
|
|
273
|
+
tool_input: dict[str, Any],
|
|
274
|
+
context: ToolContext,
|
|
275
|
+
) -> ToolResult:
|
|
276
|
+
if not self.is_enabled:
|
|
277
|
+
return ToolResult(
|
|
278
|
+
result_for_model="WebSearch is unavailable in this session because network access was not detected during initialization.",
|
|
279
|
+
is_error=True,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
query = str(tool_input.get("query", "")).strip()
|
|
283
|
+
if not query:
|
|
284
|
+
return ToolResult(result_for_model="query is required.", is_error=True)
|
|
285
|
+
|
|
286
|
+
num_results = max(1, min(int(tool_input.get("num_results", self._max_results)), _MAX_RESULTS))
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
provider, results = await self._run_search(query, num_results)
|
|
290
|
+
except DuckDuckGoUnavailable as exc:
|
|
291
|
+
logger.warning("WebSearch DuckDuckGo unavailable for query %r: %s", query, exc)
|
|
292
|
+
return ToolResult(
|
|
293
|
+
result_for_model=(
|
|
294
|
+
"WebSearch error: DuckDuckGo blocked this request with a bot check "
|
|
295
|
+
"instead of returning results."
|
|
296
|
+
),
|
|
297
|
+
is_error=True,
|
|
298
|
+
)
|
|
299
|
+
except Exception as exc:
|
|
300
|
+
logger.exception("WebSearch failed for query %r", query)
|
|
301
|
+
return ToolResult(
|
|
302
|
+
result_for_model=f"WebSearch error: {exc}",
|
|
303
|
+
is_error=True,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
if not results:
|
|
307
|
+
return ToolResult(
|
|
308
|
+
data={"provider": provider, "query": query, "results": []},
|
|
309
|
+
result_for_model="No results found.",
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
lines = []
|
|
313
|
+
for idx, item in enumerate(results, 1):
|
|
314
|
+
snippet = item["snippet"] or "(no snippet)"
|
|
315
|
+
lines.append(f"[{idx}] {item['title']}\nURL: {item['url']}\nSnippet: {snippet}")
|
|
316
|
+
|
|
317
|
+
return ToolResult(
|
|
318
|
+
data={"provider": provider, "query": query, "results": results},
|
|
319
|
+
result_for_model="\n\n".join(lines),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
async def _has_connectivity(self) -> bool:
|
|
323
|
+
for probe_url in self._probe_urls():
|
|
324
|
+
if await self._probe_endpoint(probe_url):
|
|
325
|
+
return True
|
|
326
|
+
return False
|
|
327
|
+
|
|
328
|
+
def _probe_urls(self) -> list[str]:
|
|
329
|
+
if self._provider == "tavily":
|
|
330
|
+
return [_TAVILY_URL]
|
|
331
|
+
if self._provider == "ddg":
|
|
332
|
+
return [_DDG_BASE_URL]
|
|
333
|
+
if self._api_key:
|
|
334
|
+
return [_TAVILY_URL, _DDG_BASE_URL, _BING_HOME_URL]
|
|
335
|
+
return [_DDG_BASE_URL, _BING_HOME_URL]
|
|
336
|
+
|
|
337
|
+
async def _probe_endpoint(self, url: str) -> bool:
|
|
338
|
+
try:
|
|
339
|
+
async with httpx.AsyncClient(timeout=self._timeout, follow_redirects=True) as client:
|
|
340
|
+
response = await client.get(url)
|
|
341
|
+
return response.status_code < 500
|
|
342
|
+
except Exception:
|
|
343
|
+
return False
|
|
344
|
+
|
|
345
|
+
async def _run_search(self, query: str, num_results: int) -> tuple[str, list[dict[str, str]]]:
|
|
346
|
+
if self._provider == "tavily":
|
|
347
|
+
return "tavily", await self._search_tavily(query, num_results)
|
|
348
|
+
if self._provider == "ddg":
|
|
349
|
+
if _ddg_in_backoff():
|
|
350
|
+
raise DuckDuckGoUnavailable("DuckDuckGo is still blocking this client")
|
|
351
|
+
return "ddg", await self._search_ddg(query, num_results)
|
|
352
|
+
|
|
353
|
+
if self._api_key:
|
|
354
|
+
try:
|
|
355
|
+
return "tavily", await self._search_tavily(query, num_results)
|
|
356
|
+
except Exception:
|
|
357
|
+
logger.warning("Tavily search failed; retrying with DuckDuckGo", exc_info=True)
|
|
358
|
+
|
|
359
|
+
return await self._search_ddg_then_bing(query, num_results)
|
|
360
|
+
|
|
361
|
+
async def _search_ddg_then_bing(self, query: str, num_results: int) -> tuple[str, list[dict[str, str]]]:
|
|
362
|
+
"""Use DuckDuckGo, and switch to Bing when DuckDuckGo serves a bot check."""
|
|
363
|
+
if not _ddg_in_backoff():
|
|
364
|
+
try:
|
|
365
|
+
return "ddg", await self._search_ddg(query, num_results)
|
|
366
|
+
except DuckDuckGoUnavailable:
|
|
367
|
+
logger.warning("DuckDuckGo blocked WebSearch; retrying with Bing")
|
|
368
|
+
return "bing", await self._search_bing(query, num_results)
|
|
369
|
+
|
|
370
|
+
async def _search_tavily(self, query: str, num_results: int) -> list[dict[str, str]]:
|
|
371
|
+
if not self._api_key:
|
|
372
|
+
raise RuntimeError(f"{self._api_key_env} is not configured")
|
|
373
|
+
|
|
374
|
+
payload = {
|
|
375
|
+
"api_key": self._api_key,
|
|
376
|
+
"query": query,
|
|
377
|
+
"max_results": num_results,
|
|
378
|
+
"search_depth": "basic",
|
|
379
|
+
"include_answer": False,
|
|
380
|
+
"include_raw_content": False,
|
|
381
|
+
}
|
|
382
|
+
async with httpx.AsyncClient(timeout=self._timeout, follow_redirects=True) as client:
|
|
383
|
+
response = await client.post(_TAVILY_URL, json=payload)
|
|
384
|
+
response.raise_for_status()
|
|
385
|
+
data = response.json()
|
|
386
|
+
|
|
387
|
+
raw_results = data.get("results", [])
|
|
388
|
+
return self._normalize_results(
|
|
389
|
+
{
|
|
390
|
+
"title": item.get("title", ""),
|
|
391
|
+
"url": item.get("url", ""),
|
|
392
|
+
"snippet": item.get("content", "") or item.get("snippet", ""),
|
|
393
|
+
}
|
|
394
|
+
for item in raw_results
|
|
395
|
+
)[:num_results]
|
|
396
|
+
|
|
397
|
+
async def _search_ddg(self, query: str, num_results: int) -> list[dict[str, str]]:
|
|
398
|
+
url = f"{_DDG_BASE_URL}?q={quote(query)}"
|
|
399
|
+
headers = {
|
|
400
|
+
"User-Agent": _BROWSER_UA,
|
|
401
|
+
"Accept": "text/html,application/xhtml+xml",
|
|
402
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
403
|
+
}
|
|
404
|
+
try:
|
|
405
|
+
async with httpx.AsyncClient(timeout=self._timeout, follow_redirects=True, headers=headers) as client:
|
|
406
|
+
response = await client.get(url)
|
|
407
|
+
except httpx.HTTPError as exc:
|
|
408
|
+
_note_ddg_unavailable()
|
|
409
|
+
raise DuckDuckGoUnavailable(f"DuckDuckGo request failed: {exc}") from exc
|
|
410
|
+
|
|
411
|
+
if _is_ddg_challenge(response.status_code, response.text):
|
|
412
|
+
_note_ddg_unavailable()
|
|
413
|
+
raise DuckDuckGoUnavailable("DuckDuckGo returned a bot check instead of results")
|
|
414
|
+
try:
|
|
415
|
+
response.raise_for_status()
|
|
416
|
+
except httpx.HTTPStatusError as exc:
|
|
417
|
+
_note_ddg_unavailable()
|
|
418
|
+
raise DuckDuckGoUnavailable(f"DuckDuckGo returned HTTP {response.status_code}") from exc
|
|
419
|
+
return self._normalize_results(_parse_ddg_html(response.text))[:num_results]
|
|
420
|
+
|
|
421
|
+
async def _search_bing(self, query: str, num_results: int) -> list[dict[str, str]]:
|
|
422
|
+
headers = {
|
|
423
|
+
"User-Agent": _BROWSER_UA,
|
|
424
|
+
"Accept": "application/rss+xml, application/xml, text/xml",
|
|
425
|
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
426
|
+
}
|
|
427
|
+
async with httpx.AsyncClient(timeout=self._timeout, follow_redirects=True, headers=headers) as client:
|
|
428
|
+
response = await client.get(_BING_SEARCH_URL, params={"q": query, "format": "rss"})
|
|
429
|
+
response.raise_for_status()
|
|
430
|
+
xml_text = response.text
|
|
431
|
+
return self._normalize_results(_parse_bing_rss(xml_text))[:num_results]
|
|
432
|
+
|
|
433
|
+
def _normalize_results(self, items: Any) -> list[dict[str, str]]:
|
|
434
|
+
results: list[dict[str, str]] = []
|
|
435
|
+
seen_urls: set[str] = set()
|
|
436
|
+
for item in items:
|
|
437
|
+
title = _clean_text(str(item.get("title", "")))
|
|
438
|
+
url = str(item.get("url", "")).strip()
|
|
439
|
+
snippet = _clean_text(str(item.get("snippet", "")))
|
|
440
|
+
if not title or not _is_http_url(url) or url in seen_urls:
|
|
441
|
+
continue
|
|
442
|
+
seen_urls.add(url)
|
|
443
|
+
results.append({"title": title, "url": url, "snippet": snippet})
|
|
444
|
+
return results
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Per-input statistics, independent of client connections and compaction."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from time import monotonic
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from crabcode_core.types.event import (
|
|
8
|
+
CompactEvent, CoreEvent, StreamModeEvent, StreamRetryEvent, StreamTextEvent,
|
|
9
|
+
ThinkingEvent, ToolUseEvent, TurnCompleteEvent,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TurnDetailsTracker:
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
self.started_at = datetime.now(timezone.utc).isoformat()
|
|
16
|
+
self.started_clock = monotonic()
|
|
17
|
+
self.tool_ids: set[str] = set()
|
|
18
|
+
self.thinking_count = 0
|
|
19
|
+
self.request_count = 0
|
|
20
|
+
self.retry_count = 0
|
|
21
|
+
self.compact_count = 0
|
|
22
|
+
self.thinking = False
|
|
23
|
+
|
|
24
|
+
def observe(self, event: CoreEvent) -> None:
|
|
25
|
+
# Managed-agent activity has its own transcript and statistics.
|
|
26
|
+
if getattr(event, "agent_id", None) is not None:
|
|
27
|
+
return
|
|
28
|
+
if isinstance(event, ThinkingEvent) and event.text:
|
|
29
|
+
if not self.thinking:
|
|
30
|
+
self.thinking_count += 1
|
|
31
|
+
self.thinking = True
|
|
32
|
+
elif isinstance(event, (StreamTextEvent, ToolUseEvent)):
|
|
33
|
+
self.thinking = False
|
|
34
|
+
if isinstance(event, ToolUseEvent):
|
|
35
|
+
self.tool_ids.add(event.tool_use_id)
|
|
36
|
+
elif isinstance(event, StreamModeEvent):
|
|
37
|
+
if event.mode != "thinking":
|
|
38
|
+
self.thinking = False
|
|
39
|
+
if event.mode == "requesting":
|
|
40
|
+
self.request_count += 1
|
|
41
|
+
elif isinstance(event, StreamRetryEvent):
|
|
42
|
+
self.retry_count += 1
|
|
43
|
+
self.thinking = False
|
|
44
|
+
elif isinstance(event, CompactEvent):
|
|
45
|
+
self.compact_count += 1
|
|
46
|
+
self.thinking = False
|
|
47
|
+
|
|
48
|
+
def finish(self, event: TurnCompleteEvent, *, session_id: str, model: str, provider: str) -> dict[str, Any]:
|
|
49
|
+
return {
|
|
50
|
+
"session_id": session_id,
|
|
51
|
+
"started_at": self.started_at,
|
|
52
|
+
"ended_at": datetime.now(timezone.utc).isoformat(),
|
|
53
|
+
"duration_ms": max(0, round((monotonic() - self.started_clock) * 1000)),
|
|
54
|
+
"tool_call_count": len(self.tool_ids),
|
|
55
|
+
"thinking_count": self.thinking_count,
|
|
56
|
+
"request_count": self.request_count,
|
|
57
|
+
"retry_count": self.retry_count,
|
|
58
|
+
"compact_count": self.compact_count,
|
|
59
|
+
"model": model,
|
|
60
|
+
"provider": provider,
|
|
61
|
+
"reason": event.reason,
|
|
62
|
+
"usage": dict(event.usage),
|
|
63
|
+
"source": "recorded",
|
|
64
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Core type definitions for CrabCode."""
|
|
2
|
+
|
|
3
|
+
from crabcode_core.types.message import (
|
|
4
|
+
AssistantMessage,
|
|
5
|
+
ContentBlock,
|
|
6
|
+
ImageBlock,
|
|
7
|
+
Message,
|
|
8
|
+
MessageRole,
|
|
9
|
+
SystemMessage,
|
|
10
|
+
TextBlock,
|
|
11
|
+
ThinkingBlock,
|
|
12
|
+
ToolResultBlock,
|
|
13
|
+
ToolUseBlock,
|
|
14
|
+
UserMessage,
|
|
15
|
+
)
|
|
16
|
+
from crabcode_core.types.tool import (
|
|
17
|
+
PermissionBehavior,
|
|
18
|
+
PermissionResult,
|
|
19
|
+
Tool,
|
|
20
|
+
ToolContext,
|
|
21
|
+
ToolResult,
|
|
22
|
+
)
|
|
23
|
+
from crabcode_core.types.event import (
|
|
24
|
+
AgentOutputEvent,
|
|
25
|
+
AgentStateEvent,
|
|
26
|
+
CompactEvent,
|
|
27
|
+
CoreEvent,
|
|
28
|
+
DocumentJobEvent,
|
|
29
|
+
ErrorEvent,
|
|
30
|
+
PermissionRequestEvent,
|
|
31
|
+
PermissionResponseEvent,
|
|
32
|
+
PeerMessageEvent,
|
|
33
|
+
StreamRetryEvent,
|
|
34
|
+
StreamTextEvent,
|
|
35
|
+
ThinkingEvent,
|
|
36
|
+
ToolResultEvent,
|
|
37
|
+
ToolUseEvent,
|
|
38
|
+
TurnCompleteEvent,
|
|
39
|
+
)
|
|
40
|
+
from crabcode_core.types.config import (
|
|
41
|
+
AgentSettings,
|
|
42
|
+
AgentTypeConfig,
|
|
43
|
+
AiReviewSettings,
|
|
44
|
+
ApiConfig,
|
|
45
|
+
CrabCodeSettings,
|
|
46
|
+
CrossSessionSettings,
|
|
47
|
+
LoggingSettings,
|
|
48
|
+
McpServerConfig,
|
|
49
|
+
PermissionRule,
|
|
50
|
+
PermissionsSettings,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"AssistantMessage",
|
|
55
|
+
"ContentBlock",
|
|
56
|
+
"ImageBlock",
|
|
57
|
+
"Message",
|
|
58
|
+
"MessageRole",
|
|
59
|
+
"SystemMessage",
|
|
60
|
+
"TextBlock",
|
|
61
|
+
"ThinkingBlock",
|
|
62
|
+
"ToolResultBlock",
|
|
63
|
+
"ToolUseBlock",
|
|
64
|
+
"UserMessage",
|
|
65
|
+
"PermissionBehavior",
|
|
66
|
+
"PermissionResult",
|
|
67
|
+
"Tool",
|
|
68
|
+
"ToolContext",
|
|
69
|
+
"ToolResult",
|
|
70
|
+
"CompactEvent",
|
|
71
|
+
"AgentOutputEvent",
|
|
72
|
+
"AgentStateEvent",
|
|
73
|
+
"CoreEvent",
|
|
74
|
+
"DocumentJobEvent",
|
|
75
|
+
"ErrorEvent",
|
|
76
|
+
"PermissionRequestEvent",
|
|
77
|
+
"PermissionResponseEvent",
|
|
78
|
+
"PeerMessageEvent",
|
|
79
|
+
"StreamRetryEvent",
|
|
80
|
+
"StreamTextEvent",
|
|
81
|
+
"ThinkingEvent",
|
|
82
|
+
"ToolResultEvent",
|
|
83
|
+
"ToolUseEvent",
|
|
84
|
+
"TurnCompleteEvent",
|
|
85
|
+
"ApiConfig",
|
|
86
|
+
"AgentSettings",
|
|
87
|
+
"AgentTypeConfig",
|
|
88
|
+
"AiReviewSettings",
|
|
89
|
+
"CrabCodeSettings",
|
|
90
|
+
"CrossSessionSettings",
|
|
91
|
+
"LoggingSettings",
|
|
92
|
+
"McpServerConfig",
|
|
93
|
+
"PermissionRule",
|
|
94
|
+
"PermissionsSettings",
|
|
95
|
+
]
|