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,397 @@
1
+ """Agent-level memory: rolling conversation history and turn summarization."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from dataclasses import dataclass, replace
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
9
+ from pydantic import BaseModel, Field
10
+
11
+ from opendatasci._utils.langchain_utils import (
12
+ get_final_ai_message,
13
+ get_last_turn_messages,
14
+ get_message_text_content,
15
+ get_ongoing_turn_messages,
16
+ is_ongoing_turn,
17
+ render_turn,
18
+ render_turns,
19
+ )
20
+
21
+ if TYPE_CHECKING:
22
+ from opendatasci.agents.turn_memory import AgentLoopCompactor
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ __all__ = [
27
+ "ChatHistoryBuilder",
28
+ "ChatHistoryCompactor",
29
+ "PreparedHistory",
30
+ "TurnSummaryRecord",
31
+ "TurnSummarizer",
32
+ "render_memory",
33
+ "extract_thinking_and_text",
34
+ ]
35
+
36
+
37
+ _CHAT_MEMORY_WINDOW_SIZE: int = 3
38
+
39
+
40
+ class ChatHistoryCompactor:
41
+ """Compacts older conversation turns into a summary, preserving recent turns verbatim."""
42
+
43
+ def __init__(self, llm: Any) -> None:
44
+ self._llm = llm
45
+
46
+ @staticmethod
47
+ def _validate(chat_history: list[BaseMessage]) -> list[list[BaseMessage]]:
48
+ """Parse and validate *chat_history*, returning a list of turns.
49
+
50
+ Raises:
51
+ ValueError: if the history contains an ongoing last turn, a message
52
+ that cannot belong to any turn, or an incomplete final turn.
53
+ """
54
+ if is_ongoing_turn(chat_history):
55
+ raise ValueError("Chat history has an ongoing (incomplete) last turn")
56
+
57
+ turns: list[list[BaseMessage]] = []
58
+ current_turn: list[BaseMessage] | None = None
59
+
60
+ for msg in chat_history:
61
+ if isinstance(msg, HumanMessage):
62
+ if current_turn is not None:
63
+ raise ValueError(
64
+ "Encountered a HumanMessage before the previous turn ended "
65
+ "(missing final AIMessage without tool calls)"
66
+ )
67
+ current_turn = [msg]
68
+ elif current_turn is None:
69
+ raise ValueError(f"Unexpected {type(msg).__name__} before the first HumanMessage")
70
+ else:
71
+ current_turn.append(msg)
72
+ if isinstance(msg, AIMessage) and not msg.tool_calls:
73
+ turns.append(current_turn)
74
+ current_turn = None
75
+
76
+ if current_turn is not None:
77
+ raise ValueError("Last turn is incomplete (no final AIMessage without tool calls)")
78
+
79
+ return turns
80
+
81
+ async def compact(
82
+ self,
83
+ chat_history: list[BaseMessage],
84
+ cutoff: int = 1,
85
+ ) -> list[BaseMessage]:
86
+ """Compact all turns except the last *cutoff* turns.
87
+
88
+ Turns before the cutoff are summarised by the LLM and replaced by a
89
+ single SystemMessage. The last *cutoff* turns are kept verbatim.
90
+
91
+ Returns *chat_history* unchanged when there are not enough turns to
92
+ compact (i.e. ``len(turns) <= cutoff``).
93
+
94
+ Args:
95
+ chat_history: The full conversation message list to compact.
96
+ cutoff: Number of most-recent turns to keep uncompacted. Defaults
97
+ to ``1``, meaning only the very last turn is preserved verbatim.
98
+
99
+ Returns:
100
+ A new message list with the older turns replaced by a summary followed
101
+ by the verbatim kept turns, or the original list if nothing needed
102
+ compacting.
103
+ """
104
+ from opendatasci.prompts.prompt_templates import (
105
+ CHAT_COMPACTOR_SYSTEM_PROMPT, # noqa: PLC0415
106
+ )
107
+
108
+ turns = self._validate(chat_history)
109
+
110
+ if len(turns) <= cutoff:
111
+ return list(chat_history)
112
+
113
+ turns_to_compact = turns[:-cutoff] if cutoff > 0 else turns
114
+ kept_turns = turns[-cutoff:] if cutoff > 0 else []
115
+
116
+ rendered = render_turns(turns_to_compact)
117
+
118
+ response = await self._llm.ainvoke(
119
+ [
120
+ SystemMessage(content=CHAT_COMPACTOR_SYSTEM_PROMPT),
121
+ HumanMessage(content=rendered),
122
+ ]
123
+ )
124
+ summary = response.content if isinstance(response.content, str) else str(response.content)
125
+
126
+ kept_messages: list[BaseMessage] = [msg for turn in kept_turns for msg in turn]
127
+ compaction_message = SystemMessage(
128
+ content=f"<compacted_history>\n{summary}\n</compacted_history>"
129
+ )
130
+ return [compaction_message, *kept_messages]
131
+
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # Turn summaries
135
+ # ---------------------------------------------------------------------------
136
+
137
+
138
+ @dataclass
139
+ class TurnSummaryRecord:
140
+ """Summary of a single completed conversation turn."""
141
+
142
+ turn: int
143
+ user: str
144
+ actions: str
145
+ agent: str
146
+ timestamp: str = ""
147
+
148
+ def format(self) -> str:
149
+ ts_line = f"- Timestamp: {self.timestamp}\n" if self.timestamp else ""
150
+ if "\n" in self.actions:
151
+ indented = self.actions.replace("\n", "\n ")
152
+ actions_line = f"- Outcomes:\n {indented}"
153
+ else:
154
+ actions_line = f"- Outcomes: {self.actions}"
155
+ return (
156
+ f"**Turn {self.turn}:**\n"
157
+ f"{ts_line}"
158
+ f"- User request: {self.user}\n"
159
+ f"{actions_line}\n"
160
+ f"- Agent response: {self.agent}"
161
+ )
162
+
163
+
164
+ def render_memory(
165
+ preamble: str | None,
166
+ turn_summaries: list[TurnSummaryRecord],
167
+ ) -> str:
168
+ """Render the session preamble and recent turn summaries as Markdown.
169
+
170
+ Returns an empty string when there is nothing to render.
171
+ """
172
+ parts: list[str] = []
173
+ if preamble:
174
+ parts.append(f"## Previous Session Summary\n\n{preamble}")
175
+ if turn_summaries:
176
+ lines = ["## Recent Conversation History", ""]
177
+ for summary in turn_summaries:
178
+ lines.append(summary.format())
179
+ lines.append("")
180
+ parts.append("\n".join(lines))
181
+ return "\n\n".join(parts)
182
+
183
+
184
+ @dataclass
185
+ class PreparedHistory:
186
+ """The assembled conversation context for a single LLM call.
187
+
188
+ Attributes:
189
+ messages: Human/AI/Tool messages only — never contains SystemMessages.
190
+ Mid-turn compaction is applied when the ongoing turn exceeds the budget.
191
+ memory_text: Rendered recall context (preamble + turn summaries) as a plain
192
+ string, to be passed to SystemContextBuilder. None when there is nothing
193
+ to recall.
194
+ turn_summaries: Updated rolling summary list to write back to agent state.
195
+ """
196
+
197
+ messages: list[BaseMessage]
198
+ memory_text: str | None
199
+ turn_summaries: list[TurnSummaryRecord]
200
+
201
+
202
+ class TurnSummary(BaseModel):
203
+ user_request: str = Field(
204
+ description="One sentence: what did the user ask for? Include specific names, columns, files, or constraints."
205
+ )
206
+ outcomes: str = Field(
207
+ description="Bullet points: what concretely resulted — numbers, metrics, errors, conclusions, anything produced. No filler."
208
+ )
209
+ agent_response: str = Field(
210
+ description="One or two sentences: what answer or conclusion was given to the user? Be specific."
211
+ )
212
+
213
+
214
+ class TurnSummarizer:
215
+ """Summarizes a single completed agent turn into a :class:`TurnSummaryRecord`."""
216
+
217
+ def __init__(self, summarizer_llm: Any) -> None:
218
+ self._structured_llm: Any = None
219
+ if summarizer_llm is not None:
220
+ try:
221
+ self._structured_llm = summarizer_llm.with_structured_output(TurnSummary)
222
+ except Exception:
223
+ logger.warning(
224
+ "Could not bind structured output to summarizer LLM; summarization disabled",
225
+ exc_info=True,
226
+ )
227
+
228
+ async def summarize_turn(self, turn: list[BaseMessage]) -> TurnSummaryRecord | None:
229
+ """Summarize *turn* and return a record, falling back to raw text on failure.
230
+
231
+ The returned record's ``turn`` index is left as ``0``; the caller assigns
232
+ the running turn number. Returns ``None`` for an empty turn.
233
+ """
234
+ if not turn:
235
+ return None
236
+
237
+ opening = turn[0]
238
+ timestamp = opening.additional_kwargs.get("timestamp") or ""
239
+
240
+ if self._structured_llm is not None:
241
+ try:
242
+ from opendatasci.prompts.prompt_templates import (
243
+ TURN_SUMMARIZER_SYSTEM_PROMPT, # noqa: PLC0415
244
+ )
245
+
246
+ summary: TurnSummary = await self._structured_llm.ainvoke(
247
+ [
248
+ SystemMessage(content=TURN_SUMMARIZER_SYSTEM_PROMPT),
249
+ HumanMessage(content=render_turn(turn)),
250
+ ]
251
+ )
252
+ return TurnSummaryRecord(
253
+ turn=0,
254
+ user=summary.user_request,
255
+ actions=summary.outcomes,
256
+ agent=summary.agent_response,
257
+ timestamp=timestamp,
258
+ )
259
+ except Exception:
260
+ logger.exception("Summarizer failed, using fallback")
261
+
262
+ try:
263
+ final_response = get_message_text_content(get_final_ai_message(turn)).strip()
264
+ except ValueError:
265
+ final_response = ""
266
+ return TurnSummaryRecord(
267
+ turn=0,
268
+ user=get_message_text_content(opening),
269
+ actions="(summary unavailable)",
270
+ agent=final_response,
271
+ timestamp=timestamp,
272
+ )
273
+
274
+
275
+ class ChatHistoryBuilder:
276
+ """Builds the per-call conversation history from agent state.
277
+
278
+ Handles three concerns in sequence inside :meth:`build`:
279
+
280
+ 1. Flushing any pending background turn summary and updating the rolling window.
281
+ 2. Applying mid-turn compaction when the ongoing turn exceeds the token budget.
282
+ 3. Rendering the recalled context (preamble + summaries) as plain text for the
283
+ system prompt — keeping it out of the message list entirely.
284
+
285
+ Inject an :class:`~opendatasci.agents.turn_memory.AgentLoopCompactor` and a
286
+ threshold to enable mid-turn compaction; omit both to disable it.
287
+ """
288
+
289
+ def __init__(
290
+ self,
291
+ summarizer: TurnSummarizer,
292
+ loop_compactor: "AgentLoopCompactor | None" = None,
293
+ midturn_compaction_threshold: int | None = None,
294
+ window_size: int = _CHAT_MEMORY_WINDOW_SIZE,
295
+ ) -> None:
296
+ self._summarizer = summarizer
297
+ self._loop_compactor = loop_compactor
298
+ self._midturn_compaction_threshold = midturn_compaction_threshold
299
+ self._window_size = window_size
300
+ self._pending_task: asyncio.Task[TurnSummaryRecord | None] | None = None
301
+
302
+ def schedule_turn_summarization(self, messages: list[BaseMessage]) -> None:
303
+ """Schedule background summarization of the last completed turn in *messages*.
304
+
305
+ A no-op when *messages* contains no turns. Raises when the last turn is
306
+ still in progress.
307
+
308
+ Raises:
309
+ ValueError: if the last turn is still ongoing (incomplete).
310
+ """
311
+ turn = get_last_turn_messages(messages)
312
+ if not turn:
313
+ return
314
+ if is_ongoing_turn(turn):
315
+ raise ValueError("Cannot summarize an ongoing (incomplete) turn")
316
+ self._pending_task = asyncio.create_task(self._summarizer.summarize_turn(turn))
317
+
318
+ def cancel_pending(self) -> None:
319
+ """Discard any pending summarization without recording it."""
320
+ if self._pending_task is not None:
321
+ self._pending_task.cancel()
322
+ self._pending_task = None
323
+
324
+ async def flush(self) -> TurnSummaryRecord | None:
325
+ """Await and clear the pending summarization task.
326
+
327
+ Returns ``None`` when there is no pending task or the task failed;
328
+ exceptions are swallowed and logged.
329
+ """
330
+ if self._pending_task is None:
331
+ return None
332
+ task, self._pending_task = self._pending_task, None
333
+ try:
334
+ return await task
335
+ except asyncio.CancelledError:
336
+ return None
337
+ except Exception:
338
+ logger.exception("Background summarization task failed")
339
+ return None
340
+
341
+ async def build(
342
+ self,
343
+ messages: list[BaseMessage],
344
+ turn_summaries: list[TurnSummaryRecord],
345
+ preamble: str | None = None,
346
+ ) -> PreparedHistory:
347
+ """Build the :class:`PreparedHistory` for the current LLM call.
348
+
349
+ Flushes any pending summary, trims the rolling window, applies mid-turn
350
+ compaction if needed, and renders the recalled context as plain text.
351
+ The returned :attr:`PreparedHistory.messages` never contains SystemMessages.
352
+ """
353
+ summaries = list(turn_summaries)
354
+
355
+ record = await self.flush()
356
+ if record is not None:
357
+ next_turn = summaries[-1].turn + 1 if summaries else 1
358
+ summaries.append(replace(record, turn=next_turn))
359
+ summaries = summaries[-self._window_size :]
360
+
361
+ history = list(messages)
362
+ if self._loop_compactor is not None and self._midturn_compaction_threshold is not None:
363
+ try:
364
+ turn = get_ongoing_turn_messages(history)
365
+ except ValueError:
366
+ turn = []
367
+ if (
368
+ turn
369
+ and self._loop_compactor.estimate_tokens(turn) > self._midturn_compaction_threshold
370
+ ):
371
+ compacted = await self._loop_compactor.compact(turn)
372
+ n = len(turn)
373
+ history = history[:-n] + compacted
374
+
375
+ memory_text = render_memory(preamble, summaries) or None
376
+ return PreparedHistory(messages=history, memory_text=memory_text, turn_summaries=summaries)
377
+
378
+
379
+ def extract_thinking_and_text(msg: AIMessage) -> tuple[str, str]:
380
+ """Return ``(thinking, text)`` extracted from a model response message."""
381
+ content = msg.content
382
+ if isinstance(content, str):
383
+ return "", content
384
+
385
+ thinking_parts: list[str] = []
386
+ text_parts: list[str] = []
387
+ for block in content:
388
+ if isinstance(block, dict):
389
+ btype = block.get("type", "")
390
+ if btype == "thinking":
391
+ thinking_parts.append(block.get("thinking", ""))
392
+ elif btype == "text":
393
+ text_parts.append(block.get("text", ""))
394
+ elif isinstance(block, str):
395
+ text_parts.append(block)
396
+
397
+ return "\n".join(thinking_parts), "\n".join(text_parts)
@@ -0,0 +1,84 @@
1
+ from typing import TYPE_CHECKING, Any, Callable
2
+
3
+ from langchain_core.tools import BaseTool
4
+ from langgraph.checkpoint.base import BaseCheckpointSaver
5
+ from langgraph.graph import END, START, StateGraph
6
+ from langgraph.graph.state import CompiledStateGraph
7
+ from langgraph.prebuilt import ToolNode
8
+
9
+ from opendatasci._utils.langchain_utils import is_final_ai_message
10
+ from opendatasci.agents.nodes import AgentNode, BuildSystemContext
11
+ from opendatasci.agents.states import AgentState
12
+ from opendatasci.models.factory import _RetryRunnable
13
+
14
+ if TYPE_CHECKING:
15
+ from opendatasci.agents.chat_memory import ChatHistoryBuilder
16
+
17
+
18
+ def _route_after_llm_call(state: AgentState) -> str:
19
+ return "end" if is_final_ai_message(state.messages[-1]) else "tools"
20
+
21
+
22
+ class AgentGraphFactory:
23
+ """Builds the execution graph for the main agent."""
24
+
25
+ def __init__(
26
+ self,
27
+ *,
28
+ get_llm_with_tools: Callable[[AgentState], _RetryRunnable],
29
+ tools: list[BaseTool],
30
+ build_system_context: BuildSystemContext,
31
+ chat_history_builder: "ChatHistoryBuilder | None" = None,
32
+ checkpointer: "BaseCheckpointSaver[Any] | None" = None,
33
+ ) -> None:
34
+ self._get_llm_with_tools = get_llm_with_tools
35
+ self._tools = tools
36
+ self._build_system_context = build_system_context
37
+ self._chat_history_builder = chat_history_builder
38
+ self._checkpointer = checkpointer
39
+
40
+ def build(self) -> CompiledStateGraph:
41
+ """Compile and return the graph, ready to run."""
42
+ agent_node = AgentNode(
43
+ get_llm_with_tools=self._get_llm_with_tools,
44
+ build_system_context=self._build_system_context,
45
+ chat_history_builder=self._chat_history_builder,
46
+ )
47
+
48
+ graph = StateGraph(AgentState)
49
+ graph.add_node("agent", agent_node.to_async_callable())
50
+ graph.add_node("tools", ToolNode(self._tools))
51
+ graph.add_edge(START, "agent")
52
+ graph.add_conditional_edges("agent", _route_after_llm_call, {"tools": "tools", "end": END})
53
+ graph.add_edge("tools", "agent")
54
+ return graph.compile(checkpointer=self._checkpointer)
55
+
56
+
57
+ class WorkerGraphFactory:
58
+ """Builds the execution graph for one-shot worker agents."""
59
+
60
+ def __init__(
61
+ self,
62
+ *,
63
+ llm_with_tools: _RetryRunnable,
64
+ tools: list[BaseTool],
65
+ build_system_context: BuildSystemContext,
66
+ ) -> None:
67
+ self._llm_with_tools = llm_with_tools
68
+ self._tools = tools
69
+ self._build_system_context = build_system_context
70
+
71
+ def build(self) -> CompiledStateGraph:
72
+ """Compile and return the worker graph, ready to run."""
73
+ agent_node = AgentNode(
74
+ get_llm_with_tools=lambda state: self._llm_with_tools,
75
+ build_system_context=self._build_system_context,
76
+ )
77
+
78
+ graph = StateGraph(AgentState)
79
+ graph.add_node("agent", agent_node.to_async_callable())
80
+ graph.add_node("tools", ToolNode(self._tools))
81
+ graph.add_edge(START, "agent")
82
+ graph.add_conditional_edges("agent", _route_after_llm_call, {"tools": "tools", "end": END})
83
+ graph.add_edge("tools", "agent")
84
+ return graph.compile()
@@ -0,0 +1,74 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional
3
+
4
+ from langchain_core.messages import SystemMessage
5
+ from langchain_core.runnables import RunnableConfig
6
+
7
+ from opendatasci.agents.states import AgentState
8
+ from opendatasci.models.factory import _RetryRunnable
9
+
10
+ if TYPE_CHECKING:
11
+ from opendatasci.agents.chat_memory import ChatHistoryBuilder
12
+
13
+ BuildSystemContext = Callable[[AgentState, "str | None"], list[SystemMessage]]
14
+
15
+
16
+ class BaseNode(ABC):
17
+ """Base class for all agent graph nodes.
18
+
19
+ Subclasses implement ``ainvoke()`` as the primary async entry-point.
20
+ ``to_async_callable()`` wraps it as an async callable for use in a graph.
21
+ """
22
+
23
+ @abstractmethod
24
+ async def ainvoke(
25
+ self, state: AgentState, config: Optional[RunnableConfig] = None
26
+ ) -> dict[str, Any]:
27
+ """Async entry-point; must return a partial state dict."""
28
+ ...
29
+
30
+ def to_async_callable(
31
+ self,
32
+ ) -> Callable[..., Awaitable[dict[str, Any]]]:
33
+ """Return an async callable that delegates to ``ainvoke()``."""
34
+
35
+ async def node_fn(
36
+ state: AgentState, config: Optional[RunnableConfig] = None
37
+ ) -> dict[str, Any]:
38
+ return await self.ainvoke(state, config)
39
+
40
+ return node_fn
41
+
42
+
43
+ class AgentNode(BaseNode):
44
+ """Graph node that invokes the LLM and returns the updated message list."""
45
+
46
+ def __init__(
47
+ self,
48
+ get_llm_with_tools: Callable[[AgentState], _RetryRunnable],
49
+ build_system_context: BuildSystemContext,
50
+ chat_history_builder: "ChatHistoryBuilder | None" = None,
51
+ ) -> None:
52
+ self._get_llm_with_tools = get_llm_with_tools
53
+ self._build_system_context = build_system_context
54
+ self._chat_history_builder = chat_history_builder
55
+
56
+ async def ainvoke(
57
+ self, state: AgentState, config: Optional[RunnableConfig] = None
58
+ ) -> dict[str, Any]:
59
+ updates: dict[str, Any] = {}
60
+
61
+ if self._chat_history_builder is not None:
62
+ history = await self._chat_history_builder.build(
63
+ state.messages, state.turn_summaries, state.session_preamble
64
+ )
65
+ updates["turn_summaries"] = history.turn_summaries
66
+ system = self._build_system_context(state, history.memory_text)
67
+ messages = system + history.messages
68
+ else:
69
+ system = self._build_system_context(state, None)
70
+ messages = system + list(state.messages)
71
+
72
+ response = await self._get_llm_with_tools(state).ainvoke(messages, config)
73
+ updates["messages"] = [response]
74
+ return updates
@@ -0,0 +1,36 @@
1
+ from abc import ABC
2
+ from dataclasses import dataclass, field
3
+ from typing import Annotated, Any
4
+
5
+ from langgraph.graph.message import add_messages
6
+ from langgraph.types import Interrupt
7
+
8
+ from opendatasci.agents.chat_memory import TurnSummaryRecord
9
+ from opendatasci.skills.base import Skill
10
+
11
+
12
+ @dataclass
13
+ class BaseAgentState(ABC):
14
+ """Base class for all agent states."""
15
+
16
+ interrupts: list[Interrupt] = field(default_factory=list)
17
+
18
+
19
+ @dataclass
20
+ class AgentState(BaseAgentState):
21
+ """Shared state passed between nodes in the main agent graph."""
22
+
23
+ messages: Annotated[list[Any], add_messages] = field(default_factory=list)
24
+ active_skills: list[Skill] = field(default_factory=list)
25
+ is_plan_mode: bool = False
26
+ is_self_review_mode: bool = False
27
+ turn_summaries: list[TurnSummaryRecord] = field(default_factory=list)
28
+ session_preamble: str | None = None
29
+
30
+
31
+ @dataclass
32
+ class WorkerAgentState(BaseAgentState):
33
+ """Shared state passed between nodes in a worker agent graph."""
34
+
35
+ messages: list[Any] = field(default_factory=list)
36
+ active_skills: list[Skill] = field(default_factory=list)