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,515 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import uuid
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from contextlib import AsyncExitStack
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, AsyncIterator, Callable
|
|
8
|
+
|
|
9
|
+
from langchain_core.language_models import BaseChatModel
|
|
10
|
+
from langchain_core.messages import (
|
|
11
|
+
HumanMessage,
|
|
12
|
+
RemoveMessage,
|
|
13
|
+
SystemMessage,
|
|
14
|
+
ToolMessage,
|
|
15
|
+
)
|
|
16
|
+
from langchain_core.runnables import RunnableConfig
|
|
17
|
+
from langchain_core.tools import BaseTool
|
|
18
|
+
from langgraph.checkpoint.base import BaseCheckpointSaver
|
|
19
|
+
from langgraph.checkpoint.memory import MemorySaver
|
|
20
|
+
from langgraph.graph.state import CompiledStateGraph
|
|
21
|
+
from langgraph.types import Command
|
|
22
|
+
|
|
23
|
+
from opendatasci._utils.langchain_utils import (
|
|
24
|
+
get_final_ai_message,
|
|
25
|
+
get_message_text_content,
|
|
26
|
+
is_interrupt_state_snapshot,
|
|
27
|
+
)
|
|
28
|
+
from opendatasci._utils.streaming_utils import format_stream_error
|
|
29
|
+
from opendatasci.agents.chat_memory import (
|
|
30
|
+
ChatHistoryBuilder,
|
|
31
|
+
ChatHistoryCompactor,
|
|
32
|
+
TurnSummarizer,
|
|
33
|
+
extract_thinking_and_text,
|
|
34
|
+
)
|
|
35
|
+
from opendatasci.agents.graphs import AgentGraphFactory, WorkerGraphFactory
|
|
36
|
+
from opendatasci.agents.states import AgentState
|
|
37
|
+
from opendatasci.agents.turn_memory import AgentLoopCompactor, TurnRewinder
|
|
38
|
+
from opendatasci.configs import OpenDataSciConfig
|
|
39
|
+
from opendatasci.context.base import BaseContextStore
|
|
40
|
+
from opendatasci.context.local import LocalContextStore
|
|
41
|
+
from opendatasci.models.factory import (
|
|
42
|
+
_RetryRunnable,
|
|
43
|
+
create_model,
|
|
44
|
+
create_secondary_model,
|
|
45
|
+
with_retry,
|
|
46
|
+
)
|
|
47
|
+
from opendatasci.prompts.builders import SystemContextBuilder
|
|
48
|
+
from opendatasci.prompts.caching import cached_system_prompt
|
|
49
|
+
from opendatasci.sandbox.base import BaseSandbox, BaseSandboxFactory
|
|
50
|
+
from opendatasci.sandbox.srt import SRTSandboxFactory
|
|
51
|
+
from opendatasci.skills import BaseSkillStore, LocalSkillStore
|
|
52
|
+
from opendatasci.skills.base import Skill
|
|
53
|
+
from opendatasci.streaming import (
|
|
54
|
+
AgentStreamEvent,
|
|
55
|
+
AgentTurnStreamProcessor,
|
|
56
|
+
ErrorEvent,
|
|
57
|
+
InputRequiredEvent,
|
|
58
|
+
MessageEvent,
|
|
59
|
+
ResponseEvent,
|
|
60
|
+
)
|
|
61
|
+
from opendatasci.tools import (
|
|
62
|
+
ToolName,
|
|
63
|
+
create_agent_tools,
|
|
64
|
+
)
|
|
65
|
+
from opendatasci.workspace.base import BaseWorkspace
|
|
66
|
+
|
|
67
|
+
logger = logging.getLogger(__name__)
|
|
68
|
+
|
|
69
|
+
AGENT_RECURSION_LIMIT: int = 1000
|
|
70
|
+
|
|
71
|
+
SUBAGENT_TAG: str = "opendatasci:subagent"
|
|
72
|
+
WORKER_MAX_STEPS: int = 50
|
|
73
|
+
|
|
74
|
+
# Signature: (event_type, content, metadata | None) -> None
|
|
75
|
+
OnEventCallback = Callable[[str, str, "dict[str, Any] | None"], None]
|
|
76
|
+
|
|
77
|
+
_ARGS_PREVIEW_LEN = 80
|
|
78
|
+
|
|
79
|
+
__all__ = [
|
|
80
|
+
"Agent",
|
|
81
|
+
"ConcurrentWorkerAgent",
|
|
82
|
+
"SUBAGENT_TAG",
|
|
83
|
+
"WORKER_MAX_STEPS",
|
|
84
|
+
"OnEventCallback",
|
|
85
|
+
"extract_thinking_and_text",
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class BaseOpenDataSciAgent(ABC):
|
|
90
|
+
"""Abstract interface for the data science agent."""
|
|
91
|
+
|
|
92
|
+
@abstractmethod
|
|
93
|
+
def astream(self, query: str) -> AsyncIterator[AgentStreamEvent]: ...
|
|
94
|
+
|
|
95
|
+
@abstractmethod
|
|
96
|
+
async def rewind_turn(self) -> None: ...
|
|
97
|
+
|
|
98
|
+
@abstractmethod
|
|
99
|
+
async def clear_chat_history(self) -> None: ...
|
|
100
|
+
|
|
101
|
+
@abstractmethod
|
|
102
|
+
async def compact_chat_history(self) -> str: ...
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Agent(BaseOpenDataSciAgent):
|
|
106
|
+
"""Data science and machine learning conversational AI agent.
|
|
107
|
+
|
|
108
|
+
Must be used as an async context manager; the sandbox is created on entry
|
|
109
|
+
and closed on exit::
|
|
110
|
+
|
|
111
|
+
async with Agent(...) as agent:
|
|
112
|
+
async for event in agent.astream("analyse the data"):
|
|
113
|
+
...
|
|
114
|
+
|
|
115
|
+
For most use cases prefer the :func:`create_agent` factory, which wires
|
|
116
|
+
all dependencies from a file or directory path.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
workspace: The workspace the agent operates on.
|
|
120
|
+
session_id: Identifier for this session. Generated automatically
|
|
121
|
+
when omitted.
|
|
122
|
+
context_store: Store that supplies dataset profiles and notes for the
|
|
123
|
+
active workspace and persists the agent's plan across turns. A
|
|
124
|
+
local file-based store is created when omitted.
|
|
125
|
+
skill_store: Registry that the agent queries to resolve named skills
|
|
126
|
+
at runtime. Defaults to the built-in :class:`LocalSkillStore`.
|
|
127
|
+
sandbox_factory: Factory used to create the execution sandbox.
|
|
128
|
+
The sandbox lifetime is tied to the agent's context manager scope.
|
|
129
|
+
Defaults to :class:`SRTSandboxFactory`.
|
|
130
|
+
checkpointer: Checkpoint backend for graph state. Defaults to an
|
|
131
|
+
in-memory store.
|
|
132
|
+
tools: Full set of tools available to the agent. Plan mode and
|
|
133
|
+
self-review mode use this list minus worker-spawning tools.
|
|
134
|
+
Override to restrict capabilities or inject custom tools.
|
|
135
|
+
config: LLM provider and model settings. Defaults to
|
|
136
|
+
:class:`OpenDataSciConfig` with its built-in defaults.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def __init__(
|
|
140
|
+
self,
|
|
141
|
+
workspace: BaseWorkspace,
|
|
142
|
+
context_store: BaseContextStore | None = None,
|
|
143
|
+
skill_store: BaseSkillStore | None = None,
|
|
144
|
+
sandbox_factory: BaseSandboxFactory | None = None,
|
|
145
|
+
checkpointer: BaseCheckpointSaver[Any] | None = None,
|
|
146
|
+
tools: list[BaseTool] | None = None,
|
|
147
|
+
session_id: str | None = None,
|
|
148
|
+
config: OpenDataSciConfig | None = None,
|
|
149
|
+
) -> None:
|
|
150
|
+
self._workspace = workspace
|
|
151
|
+
self._session_id = session_id or uuid.uuid4().hex
|
|
152
|
+
self._config = (config or OpenDataSciConfig()).model_copy(deep=True)
|
|
153
|
+
self._tools = tools
|
|
154
|
+
self._sandbox_factory = sandbox_factory
|
|
155
|
+
self._skill_store = skill_store
|
|
156
|
+
self._context_store = context_store
|
|
157
|
+
self._checkpointer = checkpointer
|
|
158
|
+
|
|
159
|
+
async def __aenter__(self) -> "Agent":
|
|
160
|
+
self._exit_stack = AsyncExitStack()
|
|
161
|
+
|
|
162
|
+
if self._sandbox_factory is None:
|
|
163
|
+
self._sandbox_factory = SRTSandboxFactory(
|
|
164
|
+
command_timeout=self._config.local_code_exec_timeout
|
|
165
|
+
)
|
|
166
|
+
if self._skill_store is None:
|
|
167
|
+
self._skill_store = LocalSkillStore()
|
|
168
|
+
if self._context_store is None:
|
|
169
|
+
workspace_path = Path(self._workspace.get_reference())
|
|
170
|
+
self._context_store = LocalContextStore(workspace_path=workspace_path)
|
|
171
|
+
checkpointer = self._checkpointer or MemorySaver()
|
|
172
|
+
|
|
173
|
+
self._llm: BaseChatModel = create_model(self._config)
|
|
174
|
+
self._summarizer_llm: BaseChatModel = create_secondary_model(self._config)
|
|
175
|
+
|
|
176
|
+
self._sandbox: BaseSandbox = await self._exit_stack.enter_async_context(
|
|
177
|
+
self._sandbox_factory.create(workspace_path=Path(self._workspace.get_reference()))
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if self._tools is None:
|
|
181
|
+
self._tools = create_agent_tools(
|
|
182
|
+
self._workspace,
|
|
183
|
+
self._sandbox,
|
|
184
|
+
self._context_store,
|
|
185
|
+
self._sandbox_factory,
|
|
186
|
+
store=self._skill_store,
|
|
187
|
+
datasci_config=self._config,
|
|
188
|
+
save_plan=lambda plan: self._context_store.save_plan(self._session_id, plan), # type: ignore[union-attr]
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
tools_restricted = [t for t in self._tools if t.name != ToolName.SPAWN_WORKERS]
|
|
192
|
+
self._tools_in_plan_mode: list[BaseTool] = tools_restricted
|
|
193
|
+
self._tools_in_self_review_mode: list[BaseTool] = tools_restricted
|
|
194
|
+
|
|
195
|
+
self._llm_with_tools: _RetryRunnable = with_retry(self._llm.bind_tools(self._tools))
|
|
196
|
+
self._llm_with_tools_plan: _RetryRunnable = with_retry(
|
|
197
|
+
self._llm.bind_tools(self._tools_in_plan_mode)
|
|
198
|
+
)
|
|
199
|
+
self._llm_with_tools_self_review: _RetryRunnable = with_retry(
|
|
200
|
+
self._llm.bind_tools(self._tools_in_self_review_mode)
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
self._system_context_builder = SystemContextBuilder(
|
|
204
|
+
config=self._config,
|
|
205
|
+
context_store=self._context_store,
|
|
206
|
+
session_id=self._session_id,
|
|
207
|
+
)
|
|
208
|
+
summarizer = TurnSummarizer(summarizer_llm=self._summarizer_llm)
|
|
209
|
+
loop_compactor = AgentLoopCompactor(llm=self._llm)
|
|
210
|
+
self._chat_history_builder = ChatHistoryBuilder(
|
|
211
|
+
summarizer=summarizer,
|
|
212
|
+
loop_compactor=loop_compactor,
|
|
213
|
+
midturn_compaction_threshold=self._config.midturn_compaction_threshold,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
self._graph: CompiledStateGraph = self._build_graph(checkpointer)
|
|
217
|
+
return self
|
|
218
|
+
|
|
219
|
+
async def __aexit__(self, *exc_info: Any) -> None:
|
|
220
|
+
await self._exit_stack.aclose()
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def _graph_config(self) -> RunnableConfig:
|
|
224
|
+
return {"configurable": {"thread_id": self._session_id}}
|
|
225
|
+
|
|
226
|
+
@property
|
|
227
|
+
def graph(self) -> CompiledStateGraph:
|
|
228
|
+
"""Return the underlying compiled state graph."""
|
|
229
|
+
return self._graph
|
|
230
|
+
|
|
231
|
+
def _get_active_llm_with_tools(self, state: AgentState) -> _RetryRunnable:
|
|
232
|
+
"""Return the LLM binding that matches the current agent mode."""
|
|
233
|
+
if state.is_self_review_mode:
|
|
234
|
+
return self._llm_with_tools_self_review
|
|
235
|
+
if state.is_plan_mode:
|
|
236
|
+
return self._llm_with_tools_plan
|
|
237
|
+
return self._llm_with_tools
|
|
238
|
+
|
|
239
|
+
def _build_system_context(
|
|
240
|
+
self, state: AgentState, memory_text: str | None
|
|
241
|
+
) -> list[SystemMessage]:
|
|
242
|
+
return self._system_context_builder.build(
|
|
243
|
+
active_skills=state.active_skills,
|
|
244
|
+
is_plan_mode=state.is_plan_mode,
|
|
245
|
+
is_self_review_mode=state.is_self_review_mode,
|
|
246
|
+
memory_text=memory_text,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def _build_graph(self, checkpointer: BaseCheckpointSaver[Any] | None) -> CompiledStateGraph:
|
|
250
|
+
return AgentGraphFactory(
|
|
251
|
+
get_llm_with_tools=self._get_active_llm_with_tools,
|
|
252
|
+
tools=self._tools, # type: ignore[arg-type]
|
|
253
|
+
build_system_context=self._build_system_context,
|
|
254
|
+
chat_history_builder=self._chat_history_builder,
|
|
255
|
+
checkpointer=checkpointer,
|
|
256
|
+
).build()
|
|
257
|
+
|
|
258
|
+
@classmethod
|
|
259
|
+
def _prepare_user_message(cls, query: str) -> HumanMessage:
|
|
260
|
+
"""Build the turn-opening HumanMessage.
|
|
261
|
+
|
|
262
|
+
The start timestamp and an ``is_input_on_interrupt`` flag are stored in
|
|
263
|
+
``additional_kwargs`` so the turn's start time and boundary can be
|
|
264
|
+
recovered later from the message history (see ``get_last_turn_messages``),
|
|
265
|
+
instead of being held as per-turn agent state.
|
|
266
|
+
"""
|
|
267
|
+
return HumanMessage(
|
|
268
|
+
content=query,
|
|
269
|
+
additional_kwargs={
|
|
270
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
271
|
+
"is_input_on_interrupt": False,
|
|
272
|
+
},
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# ------------------------------------------------------------------
|
|
276
|
+
# Public API
|
|
277
|
+
# ------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
async def astream(self, user_input: str) -> AsyncIterator[AgentStreamEvent]:
|
|
280
|
+
"""Stream a response to *user_input*, yielding ``AgentStreamEvent`` objects.
|
|
281
|
+
|
|
282
|
+
If the previous call ended with an ``input_required`` event, pass the
|
|
283
|
+
user's answer here to resume the interrupted graph run. Otherwise
|
|
284
|
+
*user_input* is treated as a new query.
|
|
285
|
+
"""
|
|
286
|
+
config: RunnableConfig = {
|
|
287
|
+
"recursion_limit": AGENT_RECURSION_LIMIT,
|
|
288
|
+
"configurable": {"thread_id": self._session_id},
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
graph_state = self._graph.get_state(config)
|
|
292
|
+
if is_interrupt_state_snapshot(graph_state):
|
|
293
|
+
graph_input: Any = Command(resume=user_input)
|
|
294
|
+
else:
|
|
295
|
+
self._context_store.prune() # type: ignore[union-attr]
|
|
296
|
+
user_msg = type(self)._prepare_user_message(user_input)
|
|
297
|
+
graph_input = {
|
|
298
|
+
"messages": [user_msg],
|
|
299
|
+
"active_skills": [],
|
|
300
|
+
"is_plan_mode": False,
|
|
301
|
+
"is_self_review_mode": False,
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
processor = AgentTurnStreamProcessor()
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
async for event in self._graph.astream_events(graph_input, version="v2", config=config):
|
|
308
|
+
for stream_event in processor.process_event(event): # type: ignore[arg-type]
|
|
309
|
+
if not isinstance(stream_event, MessageEvent):
|
|
310
|
+
yield stream_event
|
|
311
|
+
except Exception as exc:
|
|
312
|
+
yield ErrorEvent(content=format_stream_error(exc))
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
graph_state = self._graph.get_state(config)
|
|
316
|
+
if is_interrupt_state_snapshot(graph_state):
|
|
317
|
+
intr_value = graph_state.tasks[0].interrupts[0].value
|
|
318
|
+
yield InputRequiredEvent(
|
|
319
|
+
content=intr_value["question"],
|
|
320
|
+
choices=intr_value["choices"],
|
|
321
|
+
)
|
|
322
|
+
return
|
|
323
|
+
|
|
324
|
+
messages = graph_state.values["messages"]
|
|
325
|
+
final_ai_msg = get_final_ai_message(messages)
|
|
326
|
+
final_response = get_message_text_content(final_ai_msg).strip()
|
|
327
|
+
|
|
328
|
+
self._chat_history_builder.schedule_turn_summarization(messages)
|
|
329
|
+
|
|
330
|
+
yield ResponseEvent(content=final_response)
|
|
331
|
+
|
|
332
|
+
async def rewind_turn(self) -> None:
|
|
333
|
+
"""Remove the last turn from the conversation history."""
|
|
334
|
+
snapshot = await self._graph.aget_state(self._graph_config)
|
|
335
|
+
messages = snapshot.values.get("messages", [])
|
|
336
|
+
if not messages:
|
|
337
|
+
return
|
|
338
|
+
self._chat_history_builder.cancel_pending()
|
|
339
|
+
rewinder = TurnRewinder()
|
|
340
|
+
new_messages = rewinder.rewind_last_turn(messages)
|
|
341
|
+
removed = messages[len(new_messages) :]
|
|
342
|
+
if removed:
|
|
343
|
+
self._graph.update_state(
|
|
344
|
+
self._graph_config,
|
|
345
|
+
{"messages": [RemoveMessage(id=msg.id) for msg in removed]},
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
async def clear_chat_history(self) -> None:
|
|
349
|
+
"""Clear conversation history and rolling memory (preserves session state)."""
|
|
350
|
+
snapshot = await self._graph.aget_state(self._graph_config)
|
|
351
|
+
messages = snapshot.values.get("messages", [])
|
|
352
|
+
self._chat_history_builder.cancel_pending()
|
|
353
|
+
updates: dict[str, Any] = {"turn_summaries": [], "session_preamble": None}
|
|
354
|
+
if messages:
|
|
355
|
+
updates["messages"] = [RemoveMessage(id=msg.id) for msg in messages]
|
|
356
|
+
self._graph.update_state(self._graph_config, updates)
|
|
357
|
+
|
|
358
|
+
async def compact_chat_history(self) -> str:
|
|
359
|
+
"""Compact the conversation history using the LLM.
|
|
360
|
+
|
|
361
|
+
Older turns are summarised and discarded; the most recent turn is kept
|
|
362
|
+
verbatim. Returns the summary text, or a placeholder when there is not
|
|
363
|
+
enough history to compact.
|
|
364
|
+
"""
|
|
365
|
+
snapshot = self._graph.get_state(self._graph_config)
|
|
366
|
+
messages = snapshot.values.get("messages", [])
|
|
367
|
+
if not messages:
|
|
368
|
+
return "(no conversation to compact)"
|
|
369
|
+
|
|
370
|
+
compactor = ChatHistoryCompactor(self._llm)
|
|
371
|
+
try:
|
|
372
|
+
new_messages = await compactor.compact(messages)
|
|
373
|
+
except ValueError:
|
|
374
|
+
return "(no conversation to compact)"
|
|
375
|
+
|
|
376
|
+
if len(new_messages) == len(messages):
|
|
377
|
+
return "(no conversation to compact)"
|
|
378
|
+
|
|
379
|
+
# Extract the raw LLM summary from the leading compaction SystemMessage, stripping
|
|
380
|
+
# the <compacted_history> wrapper added by ChatHistoryCompactor, then discard the
|
|
381
|
+
# SystemMessage so it never accumulates in graph state.
|
|
382
|
+
compaction_msg = new_messages[0]
|
|
383
|
+
raw = (
|
|
384
|
+
compaction_msg.content
|
|
385
|
+
if isinstance(compaction_msg.content, str)
|
|
386
|
+
else str(compaction_msg.content)
|
|
387
|
+
)
|
|
388
|
+
_prefix, _suffix = "<compacted_history>\n", "\n</compacted_history>"
|
|
389
|
+
summary = (
|
|
390
|
+
raw[len(_prefix) : -len(_suffix)]
|
|
391
|
+
if raw.startswith(_prefix) and raw.endswith(_suffix)
|
|
392
|
+
else raw
|
|
393
|
+
)
|
|
394
|
+
kept_messages = [m for m in new_messages if not isinstance(m, SystemMessage)]
|
|
395
|
+
|
|
396
|
+
# The compacted summary becomes the session preamble so the agent retains
|
|
397
|
+
# the older context, and the rolling per-turn summaries (now folded into
|
|
398
|
+
# that summary) are reset.
|
|
399
|
+
self._chat_history_builder.cancel_pending()
|
|
400
|
+
message_updates: list[Any] = [RemoveMessage(id=msg.id) for msg in messages] + kept_messages
|
|
401
|
+
self._graph.update_state(
|
|
402
|
+
self._graph_config,
|
|
403
|
+
{
|
|
404
|
+
"messages": message_updates,
|
|
405
|
+
"session_preamble": summary,
|
|
406
|
+
"turn_summaries": [],
|
|
407
|
+
},
|
|
408
|
+
)
|
|
409
|
+
return summary
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
class ConcurrentWorkerAgent:
|
|
413
|
+
"""One-shot worker agent that executes a single delegated subtask to completion."""
|
|
414
|
+
|
|
415
|
+
def __init__(
|
|
416
|
+
self,
|
|
417
|
+
tools: list[BaseTool],
|
|
418
|
+
config: OpenDataSciConfig | None = None,
|
|
419
|
+
llm: BaseChatModel | None = None,
|
|
420
|
+
) -> None:
|
|
421
|
+
self._config = config or OpenDataSciConfig()
|
|
422
|
+
_llm = llm if llm is not None else create_model(self._config)
|
|
423
|
+
_llm_with_tools = with_retry(_llm.bind_tools(tools))
|
|
424
|
+
self._current_system_prompt: str = ""
|
|
425
|
+
|
|
426
|
+
self._graph = WorkerGraphFactory(
|
|
427
|
+
llm_with_tools=_llm_with_tools,
|
|
428
|
+
tools=tools,
|
|
429
|
+
build_system_context=self._build_system_context,
|
|
430
|
+
).build()
|
|
431
|
+
|
|
432
|
+
def _build_system_context(
|
|
433
|
+
self, state: AgentState, memory_text: str | None
|
|
434
|
+
) -> list[SystemMessage]:
|
|
435
|
+
messages: list[SystemMessage] = [
|
|
436
|
+
SystemMessage(
|
|
437
|
+
content=cached_system_prompt(self._current_system_prompt, self._config.provider) # type: ignore[arg-type]
|
|
438
|
+
)
|
|
439
|
+
]
|
|
440
|
+
for skill in state.active_skills:
|
|
441
|
+
messages.append(
|
|
442
|
+
SystemMessage(
|
|
443
|
+
content=cached_system_prompt(skill.content, self._config.provider) # type: ignore[arg-type]
|
|
444
|
+
)
|
|
445
|
+
)
|
|
446
|
+
return messages
|
|
447
|
+
|
|
448
|
+
async def ainvoke(
|
|
449
|
+
self,
|
|
450
|
+
task: str,
|
|
451
|
+
system_prompt: str,
|
|
452
|
+
on_event: OnEventCallback | None = None,
|
|
453
|
+
messages_out: "list[Any] | None" = None,
|
|
454
|
+
initial_active_skills: "list[Skill] | None" = None,
|
|
455
|
+
) -> str:
|
|
456
|
+
"""Execute *task* to completion and return the final text response."""
|
|
457
|
+
self._current_system_prompt = system_prompt
|
|
458
|
+
initial_state = AgentState(
|
|
459
|
+
messages=[HumanMessage(content=task)],
|
|
460
|
+
active_skills=list(initial_active_skills or []),
|
|
461
|
+
)
|
|
462
|
+
invoke_config: RunnableConfig = {
|
|
463
|
+
"tags": [SUBAGENT_TAG],
|
|
464
|
+
"recursion_limit": WORKER_MAX_STEPS * 2 + 1,
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
final_state: dict[str, Any] | None = None
|
|
468
|
+
|
|
469
|
+
if on_event is not None:
|
|
470
|
+
async for event in self._graph.astream_events(
|
|
471
|
+
initial_state, version="v2", config=invoke_config
|
|
472
|
+
):
|
|
473
|
+
kind = event["event"]
|
|
474
|
+
if kind == "on_tool_start":
|
|
475
|
+
tool_name = event["name"]
|
|
476
|
+
args = event["data"].get("input") or {}
|
|
477
|
+
args_preview = str(args)[:_ARGS_PREVIEW_LEN]
|
|
478
|
+
summary = args.get("summary", "") if isinstance(args, dict) else ""
|
|
479
|
+
on_event(
|
|
480
|
+
"worker_tool_call",
|
|
481
|
+
tool_name,
|
|
482
|
+
{"args_preview": args_preview, "summary": summary},
|
|
483
|
+
)
|
|
484
|
+
elif kind == "on_tool_end":
|
|
485
|
+
tool_name = event["name"]
|
|
486
|
+
output = event["data"].get("output")
|
|
487
|
+
if isinstance(output, ToolMessage):
|
|
488
|
+
content = output.content
|
|
489
|
+
elif isinstance(output, str):
|
|
490
|
+
content = output
|
|
491
|
+
else:
|
|
492
|
+
content = ""
|
|
493
|
+
is_error = isinstance(content, str) and content.startswith("Error")
|
|
494
|
+
on_event("worker_tool_result", tool_name, {"success": not is_error})
|
|
495
|
+
elif kind == "on_chain_end" and event.get("name") == "LangGraph":
|
|
496
|
+
final_state = event["data"].get("output")
|
|
497
|
+
else:
|
|
498
|
+
final_state = await self._graph.ainvoke(initial_state, config=invoke_config)
|
|
499
|
+
|
|
500
|
+
if messages_out is not None and final_state is not None:
|
|
501
|
+
final_messages = final_state.get("messages", [])
|
|
502
|
+
final_active_skills: list[Skill] = final_state.get("active_skills", [])
|
|
503
|
+
dummy_state = AgentState(messages=[], active_skills=final_active_skills)
|
|
504
|
+
sys_messages = self._build_system_context(dummy_state, None)
|
|
505
|
+
messages_out.extend([*sys_messages, *final_messages])
|
|
506
|
+
|
|
507
|
+
if final_state is None:
|
|
508
|
+
raise RuntimeError("Worker graph ended without producing output")
|
|
509
|
+
|
|
510
|
+
messages = final_state.get("messages", [])
|
|
511
|
+
if not messages:
|
|
512
|
+
raise RuntimeError("Worker graph ended with no messages")
|
|
513
|
+
|
|
514
|
+
last = messages[-1]
|
|
515
|
+
return get_message_text_content(last).strip()
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from langgraph.checkpoint.memory import MemorySaver
|
|
5
|
+
|
|
6
|
+
from opendatasci.agents.agents import Agent
|
|
7
|
+
from opendatasci.configs import OpenDataSciConfig
|
|
8
|
+
from opendatasci.context.local import LocalContextStore
|
|
9
|
+
from opendatasci.sandbox.srt import SRTSandboxFactory
|
|
10
|
+
from opendatasci.skills.local import LocalSkillStore
|
|
11
|
+
from opendatasci.workspace.local import LocalWorkspace
|
|
12
|
+
|
|
13
|
+
__all__ = ["create_agent"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create_agent(
|
|
17
|
+
path: str,
|
|
18
|
+
session_id: str | None = None,
|
|
19
|
+
config: OpenDataSciConfig | None = None,
|
|
20
|
+
) -> Agent:
|
|
21
|
+
"""Return a fully wired :class:`Agent` for a local file or directory.
|
|
22
|
+
|
|
23
|
+
The agent must be used as an async context manager so its sandbox is
|
|
24
|
+
created and closed correctly::
|
|
25
|
+
|
|
26
|
+
async with create_agent("/data/sales.csv") as agent:
|
|
27
|
+
async for event in agent.astream("summarise the data"):
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
Resolves the workspace, sandbox factory, skill store, and persistence
|
|
31
|
+
stores from *path*, then constructs and returns an agent ready to enter.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
path: Path to the data file or workspace directory to load.
|
|
35
|
+
config: LLM provider and model settings. Falls back to the library
|
|
36
|
+
default when omitted.
|
|
37
|
+
session_id: Identifier for this session's context store. Generated
|
|
38
|
+
automatically when omitted.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
FileNotFoundError: If *path* does not exist.
|
|
42
|
+
"""
|
|
43
|
+
workspace = LocalWorkspace(path)
|
|
44
|
+
|
|
45
|
+
config = config or OpenDataSciConfig()
|
|
46
|
+
workspace_path = workspace.get_reference()
|
|
47
|
+
sandbox_factory = SRTSandboxFactory()
|
|
48
|
+
context_store = LocalContextStore(Path(workspace_path))
|
|
49
|
+
|
|
50
|
+
session_id = session_id or uuid.uuid4().hex
|
|
51
|
+
|
|
52
|
+
# Skills are loaded in increasing order of precedence: the bundled built-in
|
|
53
|
+
# skills, then the workspace's own ``.opendatasci/skills/`` directory, then an
|
|
54
|
+
# explicit ``SKILLS_DIRECTORY`` override. Missing directories are skipped.
|
|
55
|
+
workspace_skills_directory = context_store.root / "skills"
|
|
56
|
+
paths: list[Path] = [config.builtin_skills_directory, workspace_skills_directory]
|
|
57
|
+
if config.skills_directory is not None:
|
|
58
|
+
paths.append(config.skills_directory)
|
|
59
|
+
skill_store = LocalSkillStore(paths)
|
|
60
|
+
|
|
61
|
+
checkpointer = MemorySaver()
|
|
62
|
+
|
|
63
|
+
return Agent(
|
|
64
|
+
workspace=workspace,
|
|
65
|
+
session_id=session_id,
|
|
66
|
+
sandbox_factory=sandbox_factory,
|
|
67
|
+
skill_store=skill_store,
|
|
68
|
+
context_store=context_store,
|
|
69
|
+
config=config,
|
|
70
|
+
checkpointer=checkpointer,
|
|
71
|
+
)
|