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
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""File-reference parsing for the @path/to/file syntax in user input."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from rich.markup import escape as escape_markup
|
|
8
|
+
|
|
9
|
+
# Matches @path/to/file or @Makefile or @.env
|
|
10
|
+
_FILE_REF_RE = re.compile(r"@(\S+?)(?=\s|$)")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _FileRef:
|
|
14
|
+
"""A @file reference parsed from user input."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, path: str) -> None:
|
|
17
|
+
self._path = path
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def display_name(self) -> str:
|
|
21
|
+
return Path(self._path).name
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _parse_file_refs(text: str) -> tuple[str, list[_FileRef]]:
|
|
25
|
+
"""Return (text_without_refs, list_of_refs)."""
|
|
26
|
+
refs = [_FileRef(path=m.group(1)) for m in _FILE_REF_RE.finditer(text)]
|
|
27
|
+
clean = _FILE_REF_RE.sub("", text).strip()
|
|
28
|
+
return clean, refs
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _build_user_display(clean_text: str, refs: list[_FileRef]) -> str:
|
|
32
|
+
"""Build the Rich-markup string shown in the user bubble."""
|
|
33
|
+
parts = []
|
|
34
|
+
for ref in refs:
|
|
35
|
+
parts.append(rf"[bold #58a6ff]\[{ref.display_name}][/bold #58a6ff]")
|
|
36
|
+
if clean_text:
|
|
37
|
+
parts.append(escape_markup(clean_text))
|
|
38
|
+
return "\n".join(parts)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _build_agent_query(clean_text: str, refs: list[_FileRef]) -> str:
|
|
42
|
+
"""Build the query string sent to the agent, with file attachment tags."""
|
|
43
|
+
if not refs:
|
|
44
|
+
return clean_text
|
|
45
|
+
parts = [clean_text] if clean_text else []
|
|
46
|
+
for ref in refs:
|
|
47
|
+
abs_path = str(Path(ref._path).resolve())
|
|
48
|
+
parts.append(f'<file_attachment path="{abs_path}" name="{ref.display_name}"/>')
|
|
49
|
+
return "\n\n".join(parts)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _split_existing_file_refs(refs: list[_FileRef]) -> tuple[list[_FileRef], list[_FileRef]]:
|
|
53
|
+
"""Split refs into (existing_files, missing_or_invalid)."""
|
|
54
|
+
existing: list[_FileRef] = []
|
|
55
|
+
missing: list[_FileRef] = []
|
|
56
|
+
for ref in refs:
|
|
57
|
+
path = Path(ref._path).expanduser()
|
|
58
|
+
if path.exists() and path.is_file():
|
|
59
|
+
existing.append(ref)
|
|
60
|
+
else:
|
|
61
|
+
missing.append(ref)
|
|
62
|
+
return existing, missing
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _find_slash_fragment(text: str) -> str | None:
|
|
66
|
+
"""Return the slash fragment if text starts with / and has no space yet."""
|
|
67
|
+
if text.startswith("/") and " " not in text:
|
|
68
|
+
return text
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _find_at_fragment(text: str) -> tuple[str, int] | None:
|
|
73
|
+
"""Return (fragment, at_index) for the last active @-reference being typed, or None."""
|
|
74
|
+
at_pos = text.rfind("@")
|
|
75
|
+
if at_pos == -1:
|
|
76
|
+
return None
|
|
77
|
+
after = text[at_pos + 1 :]
|
|
78
|
+
space_pos = after.find(" ")
|
|
79
|
+
fragment = after[:space_pos] if space_pos != -1 else after
|
|
80
|
+
return fragment, at_pos
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
_DIR_CACHE_TTL = 2.0
|
|
84
|
+
# Maps str(search_dir) → (timestamp, [(name, is_dir), ...])
|
|
85
|
+
_dir_cache: dict[str, tuple[float, list[tuple[str, bool]]]] = {}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _discover_files(fragment: str) -> list[str]:
|
|
89
|
+
"""Return up to 10 file/dir paths under cwd that match the typed fragment."""
|
|
90
|
+
frag = fragment.replace("\\", "/")
|
|
91
|
+
if "/" in frag:
|
|
92
|
+
dir_part, name_prefix = frag.rsplit("/", 1)
|
|
93
|
+
search_dir = Path(dir_part) if Path(dir_part).is_absolute() else Path.cwd() / dir_part
|
|
94
|
+
else:
|
|
95
|
+
dir_part = ""
|
|
96
|
+
name_prefix = frag
|
|
97
|
+
search_dir = Path.cwd()
|
|
98
|
+
|
|
99
|
+
if not search_dir.is_dir():
|
|
100
|
+
return []
|
|
101
|
+
|
|
102
|
+
cache_key = str(search_dir)
|
|
103
|
+
now = time.monotonic()
|
|
104
|
+
cached = _dir_cache.get(cache_key)
|
|
105
|
+
if cached is None or now - cached[0] > _DIR_CACHE_TTL:
|
|
106
|
+
try:
|
|
107
|
+
entries: list[tuple[str, bool]] = [
|
|
108
|
+
(e.name, e.is_dir()) for e in sorted(search_dir.iterdir())
|
|
109
|
+
]
|
|
110
|
+
except (PermissionError, OSError):
|
|
111
|
+
return []
|
|
112
|
+
_dir_cache[cache_key] = (now, entries)
|
|
113
|
+
else:
|
|
114
|
+
entries = cached[1]
|
|
115
|
+
|
|
116
|
+
show_hidden = name_prefix.startswith(".")
|
|
117
|
+
matches: list[str] = []
|
|
118
|
+
for name, is_dir in entries:
|
|
119
|
+
if name.startswith(".") and not show_hidden:
|
|
120
|
+
continue
|
|
121
|
+
if name_prefix and not name.lower().startswith(name_prefix.lower()):
|
|
122
|
+
continue
|
|
123
|
+
rel = (dir_part + "/" + name) if dir_part else name
|
|
124
|
+
if is_dir:
|
|
125
|
+
rel += "/"
|
|
126
|
+
matches.append(rel)
|
|
127
|
+
|
|
128
|
+
return matches[:10]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class PasteAttachment:
|
|
132
|
+
"""Multi-line text pasted by the user, shown as a compact pill in the UI.
|
|
133
|
+
|
|
134
|
+
The LLM receives the full content inside ``<pasted_content>`` tags;
|
|
135
|
+
the user sees only the pill label in the attachment bar.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(self, content: str) -> None:
|
|
139
|
+
self._content = content
|
|
140
|
+
self._line_count = content.count("\n") + 1
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def display_label(self) -> str:
|
|
144
|
+
n = self._line_count
|
|
145
|
+
return f"Text: {n} line{'s' if n != 1 else ''}"
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def pill_markup(self) -> str:
|
|
149
|
+
return rf"[bold #58a6ff]\[{escape_markup(self.display_label)}][/bold #58a6ff]"
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def xml_tag(self) -> str:
|
|
153
|
+
return f"<pasted_content>\n{self._content}\n</pasted_content>"
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""_TurnPresenter — manages all UI state for a single agent-turn stream.
|
|
2
|
+
|
|
3
|
+
Extracted from ``CLIController.run_agent`` to reduce its complexity.
|
|
4
|
+
``CLIController`` creates one ``_TurnPresenter`` per turn, feeds it events,
|
|
5
|
+
and calls ``cleanup()`` in the ``finally`` block.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from opendatasci.streaming.events import (
|
|
12
|
+
ErrorEvent,
|
|
13
|
+
ReasoningEvent,
|
|
14
|
+
ResponseEvent,
|
|
15
|
+
SubagentEvent,
|
|
16
|
+
TokenEvent,
|
|
17
|
+
ToolCallEvent,
|
|
18
|
+
ToolCommunicationEvent,
|
|
19
|
+
ToolResultEvent,
|
|
20
|
+
UsageEvent,
|
|
21
|
+
WorkerDoneEvent,
|
|
22
|
+
)
|
|
23
|
+
from opendatasci.tools import ToolName
|
|
24
|
+
|
|
25
|
+
from .adapter import EphemeralHandle, MessageHandle, ThinkingHandle, TurnStatusHandle, UIAdapter
|
|
26
|
+
from .tools_display import REGISTRY, ToolDisplay
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _TurnPresenter:
|
|
32
|
+
"""Manages ephemeral UI state (bubbles, tool blocks, thinking) for one turn.
|
|
33
|
+
|
|
34
|
+
All methods are synchronous; awaiting happens inside ``MessageBubble``
|
|
35
|
+
and ``ToolCallBlock`` via Textual's own async machinery.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, ui: UIAdapter) -> None:
|
|
39
|
+
self._ui = ui
|
|
40
|
+
self._agent_msg: MessageHandle | None = None
|
|
41
|
+
self._thinking_start: float = 0.0
|
|
42
|
+
self._had_reasoning: bool = False
|
|
43
|
+
self._ephemerals: list[EphemeralHandle] = []
|
|
44
|
+
# tool_call_id → ephemeral (promoted once tool_call fires)
|
|
45
|
+
self._ephemerals_by_id: dict[str, EphemeralHandle] = {}
|
|
46
|
+
# Ephemerals created from leading tool_communication before tool_call fires
|
|
47
|
+
self._pending_ephemerals: dict[str, EphemeralHandle] = {}
|
|
48
|
+
self._worker_block: EphemeralHandle | None = None
|
|
49
|
+
# tool_call_id → latest communication text (buffered until block is ready)
|
|
50
|
+
self._comm_buffers: dict[str, str] = {}
|
|
51
|
+
# tool_call_ids for tools with display=False — no UI created, result silently ignored
|
|
52
|
+
self._hidden_tool_call_ids: set[str] = set()
|
|
53
|
+
# Ephemeral "Thinking..." spinner shown while the LLM is processing
|
|
54
|
+
self._thinking_block: ThinkingHandle | None = None
|
|
55
|
+
self._show_thinking_block()
|
|
56
|
+
|
|
57
|
+
# ── Internal helpers ──────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
def _show_thinking_block(self) -> None:
|
|
60
|
+
if self._thinking_block is None:
|
|
61
|
+
self._thinking_block = self._ui.add_thinking_block()
|
|
62
|
+
self._had_reasoning = False
|
|
63
|
+
|
|
64
|
+
def _dismiss_thinking_block(self) -> None:
|
|
65
|
+
if self._thinking_block is not None:
|
|
66
|
+
self._thinking_block.dismiss()
|
|
67
|
+
self._thinking_block = None
|
|
68
|
+
|
|
69
|
+
def _finish_thinking(self) -> None:
|
|
70
|
+
if self._thinking_block is None:
|
|
71
|
+
return
|
|
72
|
+
if self._had_reasoning:
|
|
73
|
+
elapsed = int(time.monotonic() - self._thinking_start)
|
|
74
|
+
self._thinking_block.finish(f"Thought for {elapsed}s")
|
|
75
|
+
else:
|
|
76
|
+
self._thinking_block.dismiss()
|
|
77
|
+
self._thinking_block = None
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _make_label(tool_display: ToolDisplay | None, event: ToolCallEvent) -> str:
|
|
81
|
+
icon = tool_display.icon if tool_display else ""
|
|
82
|
+
label_text = (tool_display.label if tool_display else None) or event.tool.replace(
|
|
83
|
+
"_", " "
|
|
84
|
+
).title()
|
|
85
|
+
return f"{icon} {label_text}".strip() if icon else label_text
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def _make_summary(tool_display: ToolDisplay | None, event: ToolCallEvent) -> str:
|
|
89
|
+
icon = tool_display.icon if tool_display else ""
|
|
90
|
+
return f"{icon} {event.summary}".strip() if (icon and event.summary) else event.summary
|
|
91
|
+
|
|
92
|
+
# ── Event handlers ────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
def handle_reasoning(self, event: ReasoningEvent) -> None:
|
|
95
|
+
if not self._had_reasoning:
|
|
96
|
+
self._thinking_start = time.monotonic()
|
|
97
|
+
self._had_reasoning = True
|
|
98
|
+
|
|
99
|
+
def handle_token(self, event: TokenEvent) -> None:
|
|
100
|
+
self._finish_thinking()
|
|
101
|
+
if self._agent_msg is None:
|
|
102
|
+
self._agent_msg = self._ui.add_message("agent", "")
|
|
103
|
+
self._agent_msg.append(event.content)
|
|
104
|
+
|
|
105
|
+
def handle_tool_communication(self, event: ToolCommunicationEvent) -> None:
|
|
106
|
+
self._finish_thinking()
|
|
107
|
+
tc_id = event.tool_call_id
|
|
108
|
+
comm = event.content
|
|
109
|
+
self._comm_buffers[tc_id] = comm
|
|
110
|
+
|
|
111
|
+
if tc_id and tc_id in self._ephemerals_by_id:
|
|
112
|
+
target = self._ephemerals_by_id[tc_id]
|
|
113
|
+
if target.is_running():
|
|
114
|
+
target.set_communication(comm)
|
|
115
|
+
elif tc_id and tc_id not in self._ephemerals_by_id:
|
|
116
|
+
# First comm token — pre-mount a placeholder ephemeral.
|
|
117
|
+
block = self._ui.add_ephemeral_block(comm, "…", "")
|
|
118
|
+
self._pending_ephemerals[tc_id] = block
|
|
119
|
+
self._ephemerals.append(block)
|
|
120
|
+
self._ephemerals_by_id[tc_id] = block
|
|
121
|
+
|
|
122
|
+
def handle_tool_call(self, event: ToolCallEvent) -> None:
|
|
123
|
+
tool_call_id = event.tool_call_id or ""
|
|
124
|
+
existing = self._pending_ephemerals.pop(tool_call_id, None) if tool_call_id else None
|
|
125
|
+
tool_display = REGISTRY.get(str(event.tool))
|
|
126
|
+
|
|
127
|
+
if tool_display is not None and not tool_display.display:
|
|
128
|
+
# Tool is hidden — discard any pending comm block and never create a new one.
|
|
129
|
+
if existing is not None:
|
|
130
|
+
existing.dismiss()
|
|
131
|
+
self._ephemerals = [e for e in self._ephemerals if e is not existing]
|
|
132
|
+
self._ephemerals_by_id.pop(tool_call_id, None)
|
|
133
|
+
self._comm_buffers.pop(tool_call_id, None)
|
|
134
|
+
if tool_call_id:
|
|
135
|
+
self._hidden_tool_call_ids.add(tool_call_id)
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
self._finish_thinking()
|
|
139
|
+
has_narration = self._agent_msg is not None
|
|
140
|
+
if self._agent_msg is not None:
|
|
141
|
+
self._agent_msg.finish()
|
|
142
|
+
self._agent_msg = None
|
|
143
|
+
|
|
144
|
+
buffered_comm = self._comm_buffers.pop(tool_call_id, "")
|
|
145
|
+
comm = "" if has_narration else buffered_comm
|
|
146
|
+
|
|
147
|
+
if str(event.tool) == ToolName.SPAWN_WORKERS:
|
|
148
|
+
if existing is not None:
|
|
149
|
+
existing.dismiss()
|
|
150
|
+
self._ephemerals = [e for e in self._ephemerals if e is not existing]
|
|
151
|
+
self._ephemerals_by_id.pop(tool_call_id, None)
|
|
152
|
+
block = self._ui.add_worker_block(comm, list(event.worker_summaries))
|
|
153
|
+
self._worker_block = block
|
|
154
|
+
self._ephemerals.append(block)
|
|
155
|
+
if tool_call_id:
|
|
156
|
+
self._ephemerals_by_id[tool_call_id] = block
|
|
157
|
+
elif existing is not None:
|
|
158
|
+
if has_narration:
|
|
159
|
+
existing.set_communication(None)
|
|
160
|
+
existing.upgrade(
|
|
161
|
+
self._make_label(tool_display, event), self._make_summary(tool_display, event)
|
|
162
|
+
)
|
|
163
|
+
else:
|
|
164
|
+
block = self._ui.add_ephemeral_block(
|
|
165
|
+
comm, self._make_label(tool_display, event), self._make_summary(tool_display, event)
|
|
166
|
+
)
|
|
167
|
+
self._ephemerals.append(block)
|
|
168
|
+
if tool_call_id:
|
|
169
|
+
self._ephemerals_by_id[tool_call_id] = block
|
|
170
|
+
|
|
171
|
+
def handle_worker_done(self, event: WorkerDoneEvent) -> None:
|
|
172
|
+
if self._worker_block is not None and event.worker_idx is not None:
|
|
173
|
+
if event.success:
|
|
174
|
+
self._worker_block.mark_worker_done(event.worker_idx)
|
|
175
|
+
else:
|
|
176
|
+
self._worker_block.mark_worker_error(event.worker_idx)
|
|
177
|
+
|
|
178
|
+
def handle_subagent_event(self, event: SubagentEvent) -> None:
|
|
179
|
+
if self._worker_block is None or event.worker_idx is None:
|
|
180
|
+
return
|
|
181
|
+
if event.event_type == "worker_tool_call":
|
|
182
|
+
tool_name = event.content
|
|
183
|
+
tool_display = REGISTRY.get(tool_name)
|
|
184
|
+
activity = event.summary
|
|
185
|
+
icon = tool_display.icon if tool_display else ""
|
|
186
|
+
if activity:
|
|
187
|
+
activity = f"{icon} {activity}".strip() if icon else activity
|
|
188
|
+
elif tool_display:
|
|
189
|
+
activity = f"{icon} {tool_display.label}".strip() if icon else tool_display.label
|
|
190
|
+
else:
|
|
191
|
+
activity = tool_name
|
|
192
|
+
self._worker_block.update_worker_activity(event.worker_idx, activity)
|
|
193
|
+
elif event.event_type == "worker_tool_result":
|
|
194
|
+
# Tool finished — drop the inline activity so the row reverts to the
|
|
195
|
+
# worker's subtask summary while the LLM decides on the next step.
|
|
196
|
+
self._worker_block.update_worker_activity(event.worker_idx, "")
|
|
197
|
+
|
|
198
|
+
def handle_tool_result(self, event: ToolResultEvent) -> None:
|
|
199
|
+
tool_call_id = event.tool_call_id
|
|
200
|
+
if tool_call_id and tool_call_id in self._hidden_tool_call_ids:
|
|
201
|
+
self._hidden_tool_call_ids.discard(tool_call_id)
|
|
202
|
+
return
|
|
203
|
+
if tool_call_id and tool_call_id in self._ephemerals_by_id:
|
|
204
|
+
target = self._ephemerals_by_id.pop(tool_call_id)
|
|
205
|
+
if event.is_error:
|
|
206
|
+
target.set_error()
|
|
207
|
+
else:
|
|
208
|
+
target.set_done()
|
|
209
|
+
self._ephemerals = [e for e in self._ephemerals if e is not target]
|
|
210
|
+
else:
|
|
211
|
+
logger.warning(
|
|
212
|
+
"Received uncorrelated tool_result (tool_call_id=%r); "
|
|
213
|
+
"leaving ephemerals running until cleanup",
|
|
214
|
+
tool_call_id,
|
|
215
|
+
)
|
|
216
|
+
self._show_thinking_block()
|
|
217
|
+
|
|
218
|
+
def handle_usage(self, event: UsageEvent, turn_status: TurnStatusHandle | None) -> None:
|
|
219
|
+
input_tokens = event.input_tokens
|
|
220
|
+
output_tokens = event.output_tokens
|
|
221
|
+
cache_read_tokens = event.cache_read_tokens
|
|
222
|
+
|
|
223
|
+
context_tokens: int | None = None
|
|
224
|
+
if input_tokens is not None or output_tokens is not None:
|
|
225
|
+
context_tokens = int(input_tokens or 0) + int(output_tokens or 0)
|
|
226
|
+
|
|
227
|
+
cached_tokens: int | None = (
|
|
228
|
+
int(cache_read_tokens) if cache_read_tokens is not None else None
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if turn_status is not None:
|
|
232
|
+
turn_status.update_context(context_tokens, cached_tokens)
|
|
233
|
+
|
|
234
|
+
def handle_response(self, event: ResponseEvent) -> None:
|
|
235
|
+
self._finish_thinking()
|
|
236
|
+
if self._agent_msg is None and event.content:
|
|
237
|
+
self._agent_msg = self._ui.add_message("agent", event.content)
|
|
238
|
+
|
|
239
|
+
def handle_error(self, event: ErrorEvent) -> None:
|
|
240
|
+
self._dismiss_thinking_block()
|
|
241
|
+
if self._agent_msg is None:
|
|
242
|
+
self._agent_msg = self._ui.add_message("agent", "")
|
|
243
|
+
self._agent_msg.append(f"\n\n❌ {event.content}")
|
|
244
|
+
|
|
245
|
+
def handle_exception(self, exc: Exception) -> None:
|
|
246
|
+
self._dismiss_thinking_block()
|
|
247
|
+
if self._agent_msg is None:
|
|
248
|
+
self._agent_msg = self._ui.add_message("agent", "")
|
|
249
|
+
self._agent_msg.set_content(f"❌ **Error:** {exc}")
|
|
250
|
+
|
|
251
|
+
# ── Cleanup ───────────────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
def cleanup(self) -> None:
|
|
254
|
+
"""Finalise all open UI elements (called from the run_agent finally block)."""
|
|
255
|
+
self._finish_thinking()
|
|
256
|
+
for e in self._ephemerals:
|
|
257
|
+
e.set_done()
|
|
258
|
+
if self._agent_msg is not None:
|
|
259
|
+
self._agent_msg.finish()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""TUI service layer.
|
|
2
|
+
|
|
3
|
+
``OpenDataSciTuiService``: the single service class used by ``CLIController``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, AsyncIterator
|
|
9
|
+
|
|
10
|
+
from opendatasci.agents.agents import BaseOpenDataSciAgent
|
|
11
|
+
from opendatasci.sandbox.base import BaseSandbox
|
|
12
|
+
from opendatasci.streaming import AgentStreamEvent
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OpenDataSciTuiService:
|
|
18
|
+
"""Service layer for the OpenDataSci TUI.
|
|
19
|
+
|
|
20
|
+
Owns the agent and sandbox for the lifetime of a terminal session.
|
|
21
|
+
Create a new instance for each file or workspace loaded by the TUI.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
agent: BaseOpenDataSciAgent,
|
|
27
|
+
sandbox: BaseSandbox,
|
|
28
|
+
workspace_path: Path | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self._agent = agent
|
|
31
|
+
self._sandbox = sandbox
|
|
32
|
+
self._workspace_path = workspace_path
|
|
33
|
+
|
|
34
|
+
async def __aenter__(self) -> "OpenDataSciTuiService":
|
|
35
|
+
return self
|
|
36
|
+
|
|
37
|
+
async def __aexit__(self, *_: Any) -> None:
|
|
38
|
+
await self.close()
|
|
39
|
+
|
|
40
|
+
async def close(self) -> None:
|
|
41
|
+
"""Release sandbox resources (e.g. stop Docker containers)."""
|
|
42
|
+
await self._sandbox.close()
|
|
43
|
+
|
|
44
|
+
async def astream(self, query: str) -> AsyncIterator[AgentStreamEvent]:
|
|
45
|
+
"""Stream events for *query* with token-level output."""
|
|
46
|
+
async for event in self._agent.astream(query):
|
|
47
|
+
yield event
|
|
48
|
+
|
|
49
|
+
async def reset_session(self) -> None:
|
|
50
|
+
"""Reset the execution session and clear agent conversation."""
|
|
51
|
+
self._sandbox.reset()
|
|
52
|
+
await self._agent.clear_chat_history()
|
|
53
|
+
|
|
54
|
+
async def clear_context(self) -> None:
|
|
55
|
+
"""Clear all agent context: conversation history and memory summaries."""
|
|
56
|
+
await self._agent.clear_chat_history()
|
|
57
|
+
|
|
58
|
+
async def rewind_turn(self) -> None:
|
|
59
|
+
"""Remove the last turn from the conversation history."""
|
|
60
|
+
await self._agent.rewind_turn()
|
|
61
|
+
|
|
62
|
+
async def compact_chat_history(self) -> str:
|
|
63
|
+
"""Compact the conversation history and return the summary."""
|
|
64
|
+
return await self._agent.compact_chat_history()
|
|
65
|
+
|
|
66
|
+
def get_workspace_files(self) -> list[str]:
|
|
67
|
+
"""Return names of files/dirs visible in the workspace, relative to its root.
|
|
68
|
+
|
|
69
|
+
Used by the /ls-workspace command.
|
|
70
|
+
"""
|
|
71
|
+
if self._workspace_path is None:
|
|
72
|
+
return []
|
|
73
|
+
path = self._workspace_path
|
|
74
|
+
try:
|
|
75
|
+
entries = sorted(path.iterdir(), key=lambda f: (f.is_dir(), f.name.lower()))
|
|
76
|
+
return [e.name + ("/" if e.is_dir() else "") for e in entries]
|
|
77
|
+
except Exception:
|
|
78
|
+
return []
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""CLISessionInfo: boot-time metadata consumed only by CLIController."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from opendatasci._utils.data_formats import ALL_SUPPORTED_EXTENSIONS
|
|
9
|
+
from opendatasci.configs import OpenDataSciConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CLISessionInfo(BaseModel):
|
|
13
|
+
"""Metadata about a loaded session."""
|
|
14
|
+
|
|
15
|
+
path: str
|
|
16
|
+
is_directory: bool
|
|
17
|
+
workspace_count: int
|
|
18
|
+
workspaces: list[dict[str, Any]]
|
|
19
|
+
provider: str
|
|
20
|
+
model: str | None
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_path(
|
|
24
|
+
cls,
|
|
25
|
+
path: str,
|
|
26
|
+
workspace_path: Path | None,
|
|
27
|
+
config: OpenDataSciConfig,
|
|
28
|
+
) -> "CLISessionInfo":
|
|
29
|
+
file_path = Path(path)
|
|
30
|
+
is_dir = file_path.is_dir()
|
|
31
|
+
|
|
32
|
+
if is_dir and workspace_path is not None:
|
|
33
|
+
data_files = [
|
|
34
|
+
f
|
|
35
|
+
for f in workspace_path.rglob("*")
|
|
36
|
+
if f.is_file()
|
|
37
|
+
and f.suffix in ALL_SUPPORTED_EXTENSIONS
|
|
38
|
+
and not any(part.startswith(".") for part in f.relative_to(workspace_path).parts)
|
|
39
|
+
]
|
|
40
|
+
workspaces = [{"name": f.name} for f in data_files]
|
|
41
|
+
workspace_count = len(data_files)
|
|
42
|
+
else:
|
|
43
|
+
workspaces = [{"name": file_path.name}]
|
|
44
|
+
workspace_count = 1
|
|
45
|
+
|
|
46
|
+
return cls(
|
|
47
|
+
path=path,
|
|
48
|
+
is_directory=is_dir,
|
|
49
|
+
workspace_count=workspace_count,
|
|
50
|
+
workspaces=workspaces,
|
|
51
|
+
provider=config.provider,
|
|
52
|
+
model=config.model,
|
|
53
|
+
)
|