open-data-sci 0.1.0__py3-none-any.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.
Files changed (85) hide show
  1. open_data_sci-0.1.0.dist-info/METADATA +629 -0
  2. open_data_sci-0.1.0.dist-info/RECORD +85 -0
  3. open_data_sci-0.1.0.dist-info/WHEEL +4 -0
  4. open_data_sci-0.1.0.dist-info/entry_points.txt +2 -0
  5. open_data_sci-0.1.0.dist-info/licenses/LICENSE +201 -0
  6. opendatasci/__init__.py +47 -0
  7. opendatasci/_tui/__init__.py +1 -0
  8. opendatasci/_tui/adapter.py +102 -0
  9. opendatasci/_tui/app.py +429 -0
  10. opendatasci/_tui/commands.py +95 -0
  11. opendatasci/_tui/completion.py +139 -0
  12. opendatasci/_tui/controller.py +644 -0
  13. opendatasci/_tui/file_refs.py +153 -0
  14. opendatasci/_tui/models.py +4 -0
  15. opendatasci/_tui/presenter.py +259 -0
  16. opendatasci/_tui/service.py +78 -0
  17. opendatasci/_tui/session.py +53 -0
  18. opendatasci/_tui/styles.tcss +248 -0
  19. opendatasci/_tui/styles_visible.tcss +245 -0
  20. opendatasci/_tui/theme.py +113 -0
  21. opendatasci/_tui/tools_display.py +86 -0
  22. opendatasci/_tui/widgets.py +1001 -0
  23. opendatasci/_utils/__init__.py +0 -0
  24. opendatasci/_utils/async_utils.py +11 -0
  25. opendatasci/_utils/data_formats.py +135 -0
  26. opendatasci/_utils/hash_utils.py +52 -0
  27. opendatasci/_utils/langchain_utils.py +155 -0
  28. opendatasci/_utils/streaming_utils.py +23 -0
  29. opendatasci/agents/__init__.py +12 -0
  30. opendatasci/agents/agents.py +515 -0
  31. opendatasci/agents/agents_factory.py +71 -0
  32. opendatasci/agents/chat_memory.py +397 -0
  33. opendatasci/agents/graphs.py +84 -0
  34. opendatasci/agents/nodes.py +74 -0
  35. opendatasci/agents/states.py +36 -0
  36. opendatasci/agents/turn_memory.py +124 -0
  37. opendatasci/configs.py +275 -0
  38. opendatasci/context/__init__.py +7 -0
  39. opendatasci/context/base.py +56 -0
  40. opendatasci/context/local.py +236 -0
  41. opendatasci/models/__init__.py +7 -0
  42. opendatasci/models/anthropic.py +40 -0
  43. opendatasci/models/aws.py +86 -0
  44. opendatasci/models/factory.py +179 -0
  45. opendatasci/models/google.py +79 -0
  46. opendatasci/models/local.py +79 -0
  47. opendatasci/models/microsoft.py +62 -0
  48. opendatasci/models/openai.py +49 -0
  49. opendatasci/models/providers.py +12 -0
  50. opendatasci/prompts/__init__.py +5 -0
  51. opendatasci/prompts/builders.py +85 -0
  52. opendatasci/prompts/caching.py +42 -0
  53. opendatasci/prompts/message_templates.py +7 -0
  54. opendatasci/prompts/prompt_templates.py +227 -0
  55. opendatasci/resources/skills/competitive_data_science.md +241 -0
  56. opendatasci/resources/skills/data_science.md +55 -0
  57. opendatasci/resources/skills/data_science_education.md +42 -0
  58. opendatasci/resources/skills/deep_learning.md +205 -0
  59. opendatasci/resources/skills/machine_learning.md +68 -0
  60. opendatasci/resources/skills/quantitative_analysis.md +45 -0
  61. opendatasci/sandbox/__init__.py +14 -0
  62. opendatasci/sandbox/_runner.py +114 -0
  63. opendatasci/sandbox/base.py +170 -0
  64. opendatasci/sandbox/srt.py +490 -0
  65. opendatasci/skills/__init__.py +9 -0
  66. opendatasci/skills/base.py +28 -0
  67. opendatasci/skills/local.py +131 -0
  68. opendatasci/streaming/__init__.py +37 -0
  69. opendatasci/streaming/events.py +159 -0
  70. opendatasci/streaming/processors.py +387 -0
  71. opendatasci/tools/__init__.py +58 -0
  72. opendatasci/tools/coding.py +261 -0
  73. opendatasci/tools/critic.py +136 -0
  74. opendatasci/tools/dataset_info.py +391 -0
  75. opendatasci/tools/factory.py +172 -0
  76. opendatasci/tools/mcp.py +179 -0
  77. opendatasci/tools/planning.py +88 -0
  78. opendatasci/tools/skills.py +90 -0
  79. opendatasci/tools/user_interaction.py +54 -0
  80. opendatasci/tools/web.py +236 -0
  81. opendatasci/tools/workers.py +237 -0
  82. opendatasci/tools/workspace.py +55 -0
  83. opendatasci/workspace/__init__.py +9 -0
  84. opendatasci/workspace/base.py +20 -0
  85. opendatasci/workspace/local.py +25 -0
@@ -0,0 +1,179 @@
1
+ """MCP (Model Context Protocol) adapter: fetch tools from MCP servers and wrap as LangChain Tools."""
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Any, Optional
7
+
8
+ import httpx
9
+ from langchain_core.tools import BaseTool, StructuredTool
10
+ from pydantic import Field, create_model
11
+
12
+ OPENDATASCI_DIRNAME = ".opendatasci"
13
+ _MCP_CONFIG_FILE = "mcp.json"
14
+
15
+ _MCP_TIMEOUT = 30.0
16
+ _JSONRPC_VERSION = "2.0"
17
+
18
+ _JSON_SCHEMA_TYPE_MAP: dict[str, type] = {
19
+ "string": str,
20
+ "integer": int,
21
+ "number": float,
22
+ "boolean": bool,
23
+ "array": list,
24
+ "object": dict,
25
+ }
26
+
27
+
28
+ def _jsonrpc(method: str, params: dict[str, Any] | None = None, req_id: int = 1) -> dict[str, Any]:
29
+ payload: dict[str, Any] = {"jsonrpc": _JSONRPC_VERSION, "id": req_id, "method": method}
30
+ if params is not None:
31
+ payload["params"] = params
32
+ return payload
33
+
34
+
35
+ def _initialize(url: str) -> None:
36
+ """Perform the MCP initialization handshake with the server."""
37
+ payload = _jsonrpc(
38
+ "initialize",
39
+ {
40
+ "protocolVersion": "2024-11-05",
41
+ "capabilities": {},
42
+ "clientInfo": {"name": "opendatasci", "version": "1.0"},
43
+ },
44
+ )
45
+ with httpx.Client() as client:
46
+ client.post(url, json=payload, timeout=_MCP_TIMEOUT).raise_for_status()
47
+ # Best-effort initialized notification (servers may not require it)
48
+ notif = {"jsonrpc": _JSONRPC_VERSION, "method": "notifications/initialized"}
49
+ try:
50
+ client.post(url, json=notif, timeout=_MCP_TIMEOUT)
51
+ except Exception:
52
+ pass
53
+
54
+
55
+ def _list_tools(url: str) -> list[dict[str, Any]]:
56
+ """Fetch the tool manifest from an MCP server."""
57
+ payload = _jsonrpc("tools/list", req_id=2)
58
+ with httpx.Client() as client:
59
+ resp = client.post(url, json=payload, timeout=_MCP_TIMEOUT)
60
+ resp.raise_for_status()
61
+ data: dict[str, Any] = resp.json()
62
+ return data.get("result", {}).get("tools", []) # type: ignore[no-any-return]
63
+
64
+
65
+ def _build_args_model(tool_name: str, input_schema: dict[str, Any]) -> type:
66
+ """Convert a JSON Schema object into a Pydantic model for StructuredTool."""
67
+ properties: dict[str, Any] = input_schema.get("properties", {})
68
+ required: set[str] = set(input_schema.get("required", []))
69
+ fields: dict[str, Any] = {}
70
+
71
+ for prop_name, prop in properties.items():
72
+ py_type = _JSON_SCHEMA_TYPE_MAP.get(prop.get("type", "string"), str)
73
+ description = prop.get("description", "")
74
+ if prop_name in required:
75
+ fields[prop_name] = (py_type, Field(description=description))
76
+ else:
77
+ fields[prop_name] = (Optional[py_type], Field(default=None, description=description))
78
+
79
+ return create_model(f"_{tool_name}_args", **fields) # type: ignore[no-any-return]
80
+
81
+
82
+ def _make_mcp_tool(server_url: str, tool_def: dict[str, Any]) -> BaseTool:
83
+ """Wrap a single MCP tool definition as an async LangChain StructuredTool."""
84
+ name: str = tool_def["name"]
85
+ description: str = tool_def.get("description", "")
86
+ input_schema: dict[str, Any] = tool_def.get("inputSchema", {"type": "object", "properties": {}})
87
+ args_model = _build_args_model(name, input_schema)
88
+
89
+ async def _call(**kwargs: Any) -> str:
90
+ payload = _jsonrpc("tools/call", {"name": name, "arguments": kwargs}, req_id=3)
91
+ try:
92
+ async with httpx.AsyncClient() as client:
93
+ resp = await client.post(server_url, json=payload, timeout=_MCP_TIMEOUT)
94
+ resp.raise_for_status()
95
+ data = resp.json()
96
+ except Exception as exc:
97
+ return f"Error calling MCP tool '{name}': {type(exc).__name__}: {exc}"
98
+
99
+ if data.get("error"):
100
+ err = data["error"]
101
+ return f"MCP error {err.get('code', '')}: {err.get('message', str(err))}"
102
+
103
+ content = data.get("result", {}).get("content", [])
104
+ parts = [item.get("text", "") for item in content if item.get("type") == "text"]
105
+ if not parts:
106
+ return json.dumps(data.get("result", {}))
107
+ return "\n".join(parts)
108
+
109
+ return StructuredTool.from_function(
110
+ coroutine=_call,
111
+ name=name,
112
+ description=description,
113
+ args_schema=args_model,
114
+ )
115
+
116
+
117
+ def load_mcp_servers(workspace_path: Path) -> list[str]:
118
+ """Read MCP server URLs from ``<workspace>/.opendatasci/mcp.json``.
119
+
120
+ The file format mirrors the Cursor ``mcp.json`` convention::
121
+
122
+ {
123
+ "mcpServers": {
124
+ "my-server": { "url": "http://localhost:8080" },
125
+ "another": { "url": "http://localhost:9000" }
126
+ }
127
+ }
128
+
129
+ Returns an empty list when the file is absent, empty, or malformed
130
+ (a warning is printed to stderr in the latter case).
131
+ """
132
+ config_path = workspace_path / OPENDATASCI_DIRNAME / _MCP_CONFIG_FILE
133
+ if not config_path.exists():
134
+ return []
135
+
136
+ try:
137
+ data = json.loads(config_path.read_text(encoding="utf-8"))
138
+ servers: dict[str, dict[str, str]] = data.get("mcpServers", {})
139
+ return [entry["url"] for entry in servers.values() if "url" in entry]
140
+ except Exception as exc:
141
+ print(
142
+ f"Warning: Failed to parse {config_path}: {type(exc).__name__}: {exc}",
143
+ file=sys.stderr,
144
+ )
145
+ return []
146
+
147
+
148
+ def create_mcp_tools(server_urls: list[str]) -> list[BaseTool]:
149
+ """Query each MCP server URL, fetch its tool manifest, and return wrapped LangChain tools.
150
+
151
+ Each MCP server's tools self-register — no ``ToolName`` enum entry is required.
152
+ Servers that fail to connect or respond are skipped with a warning to stderr.
153
+
154
+ Args:
155
+ server_urls: List of MCP server base URLs (e.g. ``["http://localhost:8080"]``).
156
+
157
+ Returns:
158
+ Flat list of LangChain ``BaseTool`` instances ready to inject into any agent.
159
+ """
160
+ tools: list[BaseTool] = []
161
+ for url in server_urls:
162
+ try:
163
+ _initialize(url)
164
+ tool_defs = _list_tools(url)
165
+ except Exception as exc:
166
+ print(
167
+ f"Warning: Failed to connect to MCP server {url!r}: {type(exc).__name__}: {exc}",
168
+ file=sys.stderr,
169
+ )
170
+ continue
171
+ for td in tool_defs:
172
+ try:
173
+ tools.append(_make_mcp_tool(url, td))
174
+ except Exception as exc:
175
+ print(
176
+ f"Warning: Failed to wrap MCP tool {td.get('name')!r} from {url!r}: {exc}",
177
+ file=sys.stderr,
178
+ )
179
+ return tools
@@ -0,0 +1,88 @@
1
+ """Plan-mode tools: enter_plan_mode and exit_plan_mode."""
2
+
3
+ from typing import Annotated, Callable
4
+
5
+ from langchain_core.messages import ToolMessage
6
+ from langchain_core.tools import BaseTool, tool
7
+ from langchain_core.tools.base import InjectedToolCallId
8
+ from langgraph.types import Command
9
+
10
+ from opendatasci.agents.states import AgentState
11
+
12
+
13
+ def create_planning_tools(
14
+ save_plan: Callable[[str], None],
15
+ ) -> list[BaseTool]:
16
+ """Return ``enter_plan_mode`` and ``exit_plan_mode``.
17
+
18
+ Args:
19
+ save_plan: Callback that persists the final plan via ``BaseContextStore``.
20
+ """
21
+
22
+ @tool
23
+ def enter_plan_mode(
24
+ communication: str,
25
+ tool_call_id: Annotated[str, InjectedToolCallId],
26
+ ) -> Command[AgentState]:
27
+ """Enter Plan Mode to decompose a complex task before executing it.
28
+
29
+ In Plan Mode you can think through the full problem and produce an ordered,
30
+ actionable plan. Call ``exit_plan_mode`` with the completed plan to return to execution.
31
+
32
+ # When to use this tool
33
+ - For tasks with more than two or three interdependent steps — e.g. building a
34
+ full ML pipeline, multi-stage analysis, or anything where step ordering matters.
35
+
36
+ # When NOT to use this tool
37
+ - For simple tasks — the overhead is wasteful.
38
+
39
+ Args:
40
+ communication: Brief message to the user about what you're doing
41
+ (e.g. "This task has several interdependent steps — let me plan it first.").
42
+ """
43
+ return Command(
44
+ update={
45
+ "is_plan_mode": True,
46
+ "messages": [
47
+ ToolMessage(
48
+ content=(
49
+ "Plan Mode active. Think through the full task carefully and produce a "
50
+ "detailed, ordered plan. Call exit_plan_mode once your plan is complete."
51
+ ),
52
+ tool_call_id=tool_call_id,
53
+ )
54
+ ],
55
+ }
56
+ )
57
+
58
+ @tool
59
+ def exit_plan_mode(
60
+ final_plan: str,
61
+ tool_call_id: Annotated[str, InjectedToolCallId],
62
+ ) -> Command[AgentState]:
63
+ """Exit Plan Mode and record the completed plan.
64
+
65
+ The plan is persisted and available as context throughout execution. Write each
66
+ step as a concise, single-action description; sequence steps so each one's output
67
+ feeds naturally into the next.
68
+
69
+ Args:
70
+ final_plan: The complete, ordered plan.
71
+ """
72
+ save_plan(final_plan)
73
+ return Command(
74
+ update={
75
+ "is_plan_mode": False,
76
+ "messages": [
77
+ ToolMessage(
78
+ content=(
79
+ "Plan recorded and saved. You are back in execution mode. "
80
+ "The plan is now part of your context — work through it step by step."
81
+ ),
82
+ tool_call_id=tool_call_id,
83
+ )
84
+ ],
85
+ }
86
+ )
87
+
88
+ return [enter_plan_mode, exit_plan_mode]
@@ -0,0 +1,90 @@
1
+ """Skills tool: load a specialised skill profile into the agent's system prompt."""
2
+
3
+ from typing import Annotated
4
+
5
+ from langchain_core.messages import ToolMessage
6
+ from langchain_core.tools import BaseTool, tool
7
+ from langchain_core.tools.base import InjectedToolCallId
8
+ from langgraph.prebuilt import InjectedState
9
+ from langgraph.types import Command
10
+
11
+ from opendatasci.agents.states import AgentState
12
+ from opendatasci.skills import SKILL_LABELS, Skill
13
+ from opendatasci.skills.base import BaseSkillStore
14
+
15
+
16
+ def _label_for(skill: Skill) -> str:
17
+ return SKILL_LABELS.get(skill.name) or skill.name.replace("_", " ").title()
18
+
19
+
20
+ def create_skill_tools(store: BaseSkillStore) -> list[BaseTool]:
21
+ """Return the skill tools bound to *store*."""
22
+ return [_create_skill_tool(store)]
23
+
24
+
25
+ def _create_skill_tool(store: BaseSkillStore) -> BaseTool:
26
+ @tool
27
+ def load_skill(
28
+ skill: str,
29
+ summary: str,
30
+ communication: str,
31
+ state: Annotated[AgentState, InjectedState],
32
+ tool_call_id: Annotated[str, InjectedToolCallId],
33
+ ) -> Command[AgentState]:
34
+ """Load a specialised skill profile to sharpen domain-specific guidance.
35
+
36
+ Only one skill is active at a time; loading a new one replaces the previous.
37
+
38
+ # When to use this tool
39
+ - At the start of a domain-specific task to get targeted guidance and best practices.
40
+ - When switching task domains mid-session (e.g. from data wrangling to model training).
41
+
42
+ # Available skills
43
+ ``data_science``, ``competitive_data_science``,
44
+ ``competitive_data_science_v2``, ``machine_learning``,
45
+ ``deep_learning``, ``quantitative_analysis``, ``data_science_education``.
46
+
47
+ Args:
48
+ skill: Profile name to load.
49
+ summary: 3-4 word status label (e.g. "Loading data science skill").
50
+ communication: Brief message to the user about what you're doing
51
+ (e.g. "Let me load the data science skill for this task.").
52
+ """
53
+ active = state.active_skills
54
+ if active and active[0].name == skill:
55
+ return Command(
56
+ update={
57
+ "messages": [
58
+ ToolMessage(
59
+ content="This skill is already loaded.", tool_call_id=tool_call_id
60
+ )
61
+ ]
62
+ }
63
+ )
64
+
65
+ loaded = store.load(skill)
66
+ if loaded is None:
67
+ available = ", ".join(sorted(store.list()))
68
+ return Command(
69
+ update={
70
+ "messages": [
71
+ ToolMessage(
72
+ content=f"Unknown skill '{skill}'. Available: {available}",
73
+ tool_call_id=tool_call_id,
74
+ )
75
+ ]
76
+ }
77
+ )
78
+
79
+ return Command(
80
+ update={
81
+ "active_skills": [loaded],
82
+ "messages": [
83
+ ToolMessage(
84
+ content=f"{_label_for(loaded)} skill loaded.", tool_call_id=tool_call_id
85
+ )
86
+ ],
87
+ }
88
+ )
89
+
90
+ return load_skill
@@ -0,0 +1,54 @@
1
+ """User interaction tools: ask_user_mcq."""
2
+
3
+ from langchain_core.tools import BaseTool, tool
4
+ from langgraph.types import interrupt
5
+
6
+
7
+ def create_user_interaction_tools() -> list[BaseTool]:
8
+ """Return user interaction tools that pause the graph to ask the user a question.
9
+
10
+ Uses LangGraph's ``interrupt()`` mechanism: the graph is paused and its state
11
+ is persisted to the checkpointer until the caller resumes it via
12
+ ``Command(resume=answer)``.
13
+
14
+ Identical questions are deduplicated: the first answer is cached per tool
15
+ instance so the agent never asks the user the same MCQ twice.
16
+ """
17
+ _cache: dict[tuple[str, str, str, str], str] = {}
18
+
19
+ @tool
20
+ def ask_user_mcq(
21
+ question: str,
22
+ choice_a: str,
23
+ choice_b: str,
24
+ choice_c: str,
25
+ ) -> str:
26
+ """Ask the user a multiple-choice question when the task cannot proceed without their input.
27
+
28
+ Presents three predefined choices (A, B, C). The user may also type a free-form answer —
29
+ treat any response that doesn't match a choice as a custom answer.
30
+
31
+ # When to use this tool
32
+ - When the problem is genuinely underspecified and the right approach depends on
33
+ an unstated user goal.
34
+ - When you need the user's input to make an assumption — ask only when correctness cannot be verified by available means.
35
+
36
+ # When NOT to use this tool
37
+ - For technical decisions you can make yourself — do not delegate judgment.
38
+ - When a reasonable assumption would unblock the task — ask only if truly blocked.
39
+
40
+ Args:
41
+ question: The question to ask.
42
+ choice_a: Text for option A.
43
+ choice_b: Text for option B.
44
+ choice_c: Text for option C.
45
+ """
46
+ key = (question, choice_a, choice_b, choice_c)
47
+ if key in _cache:
48
+ return _cache[key]
49
+
50
+ answer: str = interrupt({"question": question, "choices": [choice_a, choice_b, choice_c]})
51
+ _cache[key] = answer
52
+ return answer
53
+
54
+ return [ask_user_mcq]
@@ -0,0 +1,236 @@
1
+ """Web tools: web_search and fetch_url."""
2
+
3
+ import re
4
+ from collections.abc import Iterable
5
+ from functools import lru_cache
6
+ from typing import Annotated
7
+ from urllib.parse import urlparse
8
+
9
+ from annotated_types import Ge
10
+ from langchain_core.tools import BaseTool, tool
11
+
12
+ # Domains permitted for fetch_url. A URL is allowed when its hostname equals
13
+ # one of these entries *or* is a subdomain of one (e.g. "en.wikipedia.org"
14
+ # matches "wikipedia.org").
15
+ _SEARCH_limit: int = 10
16
+ _SEARCH_SNIPPET_MAX_CHARS: int = 300
17
+
18
+ _FETCH_ALLOWED_DOMAINS: frozenset[str] = frozenset(
19
+ {
20
+ # Code & documentation
21
+ "raw.githubusercontent.com",
22
+ "github.com",
23
+ "docs.python.org",
24
+ "pandas.pydata.org",
25
+ "numpy.org",
26
+ "scikit-learn.org",
27
+ "matplotlib.org",
28
+ "scipy.org",
29
+ # ONNX ecosystem
30
+ "onnx.ai",
31
+ "onnxruntime.ai",
32
+ # Research
33
+ "arxiv.org",
34
+ # Finance & open APIs
35
+ "finance.yahoo.com",
36
+ # Competitive data science
37
+ "kaggle.com",
38
+ }
39
+ )
40
+
41
+
42
+ _EMPTY_DOMAINS: frozenset[str] = frozenset()
43
+
44
+
45
+ def _is_domain_allowed(
46
+ url: str,
47
+ extra: frozenset[str] = _EMPTY_DOMAINS,
48
+ override: frozenset[str] | None = None,
49
+ ) -> bool:
50
+ """Return True when *url*'s hostname is in or is a subdomain of the allowed set.
51
+
52
+ Args:
53
+ url: The URL to check.
54
+ extra: Additional domains unioned with the base set.
55
+ override: When provided, replaces ``_FETCH_ALLOWED_DOMAINS`` entirely
56
+ before ``extra`` is applied.
57
+ """
58
+ try:
59
+ host = (urlparse(url).hostname or "").lower()
60
+ except Exception:
61
+ return False
62
+ base = override if override is not None else _FETCH_ALLOWED_DOMAINS
63
+ allowed = base | extra
64
+ return any(host == d or host.endswith("." + d) for d in allowed)
65
+
66
+
67
+ def _clean_html(content: str) -> str:
68
+ """Return clean plain text extracted from *content* (HTML)."""
69
+ try:
70
+ from lxml import html as lxml_html
71
+
72
+ doc = lxml_html.fromstring(content)
73
+ for el in doc.xpath(
74
+ "//script | //style | //nav | //header | //footer | //aside | //noscript"
75
+ ):
76
+ parent = el.getparent()
77
+ if parent is not None:
78
+ parent.remove(el)
79
+ text: str = doc.text_content()
80
+ except Exception:
81
+ text = re.sub(
82
+ r"<\s*script\b[^>]*>.*?<\s*/\s*script\b[^>]*>",
83
+ "",
84
+ content,
85
+ flags=re.DOTALL | re.IGNORECASE,
86
+ )
87
+ text = re.sub(
88
+ r"<\s*style\b[^>]*>.*?<\s*/\s*style\b[^>]*>", "", text, flags=re.DOTALL | re.IGNORECASE
89
+ )
90
+ text = re.sub(r"<[^>]+>", "", text)
91
+
92
+ lines = [ln.strip() for ln in text.splitlines()]
93
+ text = "\n".join(ln for ln in lines if ln)
94
+ text = re.sub(r"\n{3,}", "\n\n", text)
95
+ return text
96
+
97
+
98
+ @lru_cache(maxsize=16)
99
+ async def _web_search_impl(query: str, limit: int) -> str:
100
+ try:
101
+ from duckduckgo_search import DDGS
102
+ except ImportError:
103
+ return "Error: duckduckgo-search is not installed. Run: pip install duckduckgo-search"
104
+
105
+ n = max(1, min(int(limit), _SEARCH_limit))
106
+ try:
107
+ results = [r async for r in DDGS().atext(query, limit=n)] # type: ignore[attr-defined]
108
+ except Exception as exc:
109
+ return f"Error performing web search: {type(exc).__name__}: {exc}"
110
+
111
+ if not results:
112
+ return "No results found."
113
+
114
+ lines = []
115
+ for i, r in enumerate(results, 1):
116
+ line = f"{i}. {r['title']} — {r['href']}"
117
+ body = (r.get("body") or "").strip()[:_SEARCH_SNIPPET_MAX_CHARS]
118
+ if body:
119
+ line += f"\n {body}"
120
+ lines.append(line)
121
+ return "\n".join(lines)
122
+
123
+
124
+ @tool
125
+ async def web_search(
126
+ query: str, summary: str, communication: str, limit: Annotated[int, Ge(1)] = 10
127
+ ) -> str:
128
+ """Search the web for resources, documentation, data sources, or reference pages.
129
+
130
+ Returns titles, URLs, and short snippets. Follow up with ``fetch_url`` to retrieve full content.
131
+
132
+ # When to use this tool
133
+ - To discover data sources, APIs, documentation, or research papers.
134
+ - When you don't know the exact URL of the resource you need.
135
+
136
+ # How to use this tool
137
+ - Keep queries specific: include key terms rather than full sentences.
138
+ - Follow up with ``fetch_url`` on the most relevant result to get full content.
139
+
140
+ Args:
141
+ query: Search query (natural language or keywords).
142
+ summary: 3-4 word status label (e.g. "Searching BLS data").
143
+ communication: Brief message to the user about what you're doing
144
+ (e.g. "Let me search for data sources that could be useful for this task.").
145
+ limit: Number of results to return.
146
+ """
147
+ return await _web_search_impl(query, limit)
148
+
149
+
150
+ def _make_fetch_url_tool(
151
+ extra: frozenset[str],
152
+ override: frozenset[str] | None = None,
153
+ ) -> BaseTool:
154
+ """Return a fetch_url tool bound to the given domain sets."""
155
+
156
+ if override is not None:
157
+ allowed_domains = override
158
+ else:
159
+ allowed_domains = _FETCH_ALLOWED_DOMAINS | extra
160
+
161
+ @lru_cache(maxsize=16)
162
+ async def _fetch_url_impl(url: str) -> str:
163
+ try:
164
+ import httpx
165
+ except ImportError:
166
+ return "Error: httpx is not installed. Run: pip install httpx"
167
+
168
+ if not _is_domain_allowed(url, extra, override):
169
+ host = urlparse(url).hostname or url
170
+ return (
171
+ f"Error: Domain '{host}' is not in the fetch allowlist. "
172
+ "Use web_search to find content from an allowed domain, then fetch that URL."
173
+ )
174
+
175
+ try:
176
+ async with httpx.AsyncClient(
177
+ follow_redirects=True,
178
+ timeout=20,
179
+ headers={"User-Agent": "Mozilla/5.0 (compatible; OpenDataSci/1.0)"},
180
+ ) as client:
181
+ response = await client.get(url)
182
+ response.raise_for_status()
183
+ except httpx.TimeoutException:
184
+ return "Error: Request timed out after 20 seconds."
185
+ except httpx.HTTPStatusError as exc:
186
+ return f"Error: HTTP {exc.response.status_code} fetching {url}"
187
+ except Exception as exc:
188
+ return f"Error fetching URL: {type(exc).__name__}: {exc}"
189
+
190
+ content_type = response.headers.get("content-type", "").lower()
191
+ if "html" in content_type:
192
+ return _clean_html(response.text)
193
+ return response.text
194
+
195
+ async def fetch_url(url: str, summary: str, communication: str) -> str:
196
+ return await _fetch_url_impl(url)
197
+
198
+ sorted_domains = ", ".join(sorted(allowed_domains))
199
+ fetch_url.__doc__ = (
200
+ f"Fetch the full plain-text content of a URL from an allowed domain.\n\n"
201
+ f"Allowed domains: {sorted_domains}\n\n"
202
+ f"# When to use this tool\n"
203
+ f"- When you have a specific URL from an allowed domain to retrieve.\n"
204
+ f"- To read documentation, papers, or data from a page found via ``web_search``.\n\n"
205
+ f"# When NOT to use this tool\n"
206
+ f"- When the target domain is not in the allowlist — use ``web_search`` instead\n"
207
+ f" to find useful links that resolve to an allowed domain.\n\n"
208
+ f"Args:\n"
209
+ f" url: Full URL to fetch (must be from an allowed domain).\n"
210
+ f' summary: 3-4 word status label (e.g. "Fetching BLS report").\n'
211
+ f" communication: Brief message to the user about what you're doing\n"
212
+ f' (e.g. "Let me fetch this research paper.").\n'
213
+ )
214
+
215
+ return tool(fetch_url)
216
+
217
+
218
+ def create_web_tools(
219
+ extra_web_domains: Iterable[str] = (),
220
+ override_web_domains: Iterable[str] | None = None,
221
+ ) -> list[BaseTool]:
222
+ """Return the web_search and fetch_url tools (main agent only).
223
+
224
+ Args:
225
+ extra_web_domains: Additional hostnames (or apex domains) to permit
226
+ in ``fetch_url``, beyond the base allowlist.
227
+ override_web_domains: When provided, replaces the built-in allowlist
228
+ entirely. ``extra_web_domains`` is still applied on top.
229
+ """
230
+ extra = frozenset(d.lower().strip() for d in extra_web_domains)
231
+ override = (
232
+ frozenset(d.lower().strip() for d in override_web_domains)
233
+ if override_web_domains is not None
234
+ else None
235
+ )
236
+ return [web_search, _make_fetch_url_tool(extra, override)]