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