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,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
+ )