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.
- open_data_sci-0.1.0.dist-info/METADATA +629 -0
- open_data_sci-0.1.0.dist-info/RECORD +85 -0
- open_data_sci-0.1.0.dist-info/WHEEL +4 -0
- open_data_sci-0.1.0.dist-info/entry_points.txt +2 -0
- open_data_sci-0.1.0.dist-info/licenses/LICENSE +201 -0
- opendatasci/__init__.py +47 -0
- opendatasci/_tui/__init__.py +1 -0
- opendatasci/_tui/adapter.py +102 -0
- opendatasci/_tui/app.py +429 -0
- opendatasci/_tui/commands.py +95 -0
- opendatasci/_tui/completion.py +139 -0
- opendatasci/_tui/controller.py +644 -0
- opendatasci/_tui/file_refs.py +153 -0
- opendatasci/_tui/models.py +4 -0
- opendatasci/_tui/presenter.py +259 -0
- opendatasci/_tui/service.py +78 -0
- opendatasci/_tui/session.py +53 -0
- opendatasci/_tui/styles.tcss +248 -0
- opendatasci/_tui/styles_visible.tcss +245 -0
- opendatasci/_tui/theme.py +113 -0
- opendatasci/_tui/tools_display.py +86 -0
- opendatasci/_tui/widgets.py +1001 -0
- opendatasci/_utils/__init__.py +0 -0
- opendatasci/_utils/async_utils.py +11 -0
- opendatasci/_utils/data_formats.py +135 -0
- opendatasci/_utils/hash_utils.py +52 -0
- opendatasci/_utils/langchain_utils.py +155 -0
- opendatasci/_utils/streaming_utils.py +23 -0
- opendatasci/agents/__init__.py +12 -0
- opendatasci/agents/agents.py +515 -0
- opendatasci/agents/agents_factory.py +71 -0
- opendatasci/agents/chat_memory.py +397 -0
- opendatasci/agents/graphs.py +84 -0
- opendatasci/agents/nodes.py +74 -0
- opendatasci/agents/states.py +36 -0
- opendatasci/agents/turn_memory.py +124 -0
- opendatasci/configs.py +275 -0
- opendatasci/context/__init__.py +7 -0
- opendatasci/context/base.py +56 -0
- opendatasci/context/local.py +236 -0
- opendatasci/models/__init__.py +7 -0
- opendatasci/models/anthropic.py +40 -0
- opendatasci/models/aws.py +86 -0
- opendatasci/models/factory.py +179 -0
- opendatasci/models/google.py +79 -0
- opendatasci/models/local.py +79 -0
- opendatasci/models/microsoft.py +62 -0
- opendatasci/models/openai.py +49 -0
- opendatasci/models/providers.py +12 -0
- opendatasci/prompts/__init__.py +5 -0
- opendatasci/prompts/builders.py +85 -0
- opendatasci/prompts/caching.py +42 -0
- opendatasci/prompts/message_templates.py +7 -0
- opendatasci/prompts/prompt_templates.py +227 -0
- opendatasci/resources/skills/competitive_data_science.md +241 -0
- opendatasci/resources/skills/data_science.md +55 -0
- opendatasci/resources/skills/data_science_education.md +42 -0
- opendatasci/resources/skills/deep_learning.md +205 -0
- opendatasci/resources/skills/machine_learning.md +68 -0
- opendatasci/resources/skills/quantitative_analysis.md +45 -0
- opendatasci/sandbox/__init__.py +14 -0
- opendatasci/sandbox/_runner.py +114 -0
- opendatasci/sandbox/base.py +170 -0
- opendatasci/sandbox/srt.py +490 -0
- opendatasci/skills/__init__.py +9 -0
- opendatasci/skills/base.py +28 -0
- opendatasci/skills/local.py +131 -0
- opendatasci/streaming/__init__.py +37 -0
- opendatasci/streaming/events.py +159 -0
- opendatasci/streaming/processors.py +387 -0
- opendatasci/tools/__init__.py +58 -0
- opendatasci/tools/coding.py +261 -0
- opendatasci/tools/critic.py +136 -0
- opendatasci/tools/dataset_info.py +391 -0
- opendatasci/tools/factory.py +172 -0
- opendatasci/tools/mcp.py +179 -0
- opendatasci/tools/planning.py +88 -0
- opendatasci/tools/skills.py +90 -0
- opendatasci/tools/user_interaction.py +54 -0
- opendatasci/tools/web.py +236 -0
- opendatasci/tools/workers.py +237 -0
- opendatasci/tools/workspace.py +55 -0
- opendatasci/workspace/__init__.py +9 -0
- opendatasci/workspace/base.py +20 -0
- opendatasci/workspace/local.py +25 -0
opendatasci/tools/mcp.py
ADDED
|
@@ -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]
|
opendatasci/tools/web.py
ADDED
|
@@ -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)]
|