chuk-ai-session-manager 0.1.1__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.
@@ -0,0 +1,474 @@
1
+ # chuk_ai_session_manager/session_prompt_builder.py
2
+ """
3
+ Build optimized prompts for LLM calls from Session objects with async support.
4
+
5
+ This module provides flexible prompt construction from session data,
6
+ with support for token management, relevance-based selection,
7
+ and hierarchical context awareness.
8
+ """
9
+
10
+ from __future__ import annotations
11
+ import json
12
+ import logging
13
+ from typing import List, Dict, Any, Optional, Literal, Union
14
+ from enum import Enum
15
+ import asyncio
16
+
17
+ from chuk_ai_session_manager.models.session import Session
18
+ from chuk_ai_session_manager.models.event_type import EventType
19
+ from chuk_ai_session_manager.models.event_source import EventSource
20
+ from chuk_ai_session_manager.models.token_usage import TokenUsage
21
+ from chuk_ai_session_manager.storage import SessionStoreProvider
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ class PromptStrategy(str, Enum):
26
+ """Different strategies for building prompts."""
27
+ MINIMAL = "minimal" # Original minimal approach
28
+ TASK_FOCUSED = "task" # Focus on the task with minimal context
29
+ TOOL_FOCUSED = "tool" # Emphasize tool usage and results
30
+ CONVERSATION = "conversation" # Include more conversation history
31
+ HIERARCHICAL = "hierarchical" # Include parent session context
32
+
33
+
34
+ async def build_prompt_from_session(
35
+ session: Session,
36
+ strategy: Union[PromptStrategy, str] = PromptStrategy.MINIMAL,
37
+ max_tokens: Optional[int] = None,
38
+ model: str = "gpt-3.5-turbo",
39
+ include_parent_context: bool = False,
40
+ current_query: Optional[str] = None
41
+ ) -> List[Dict[str, str]]:
42
+ """
43
+ Build a prompt for the next LLM call from a Session asynchronously.
44
+
45
+ Args:
46
+ session: The session to build a prompt from
47
+ strategy: Prompt building strategy to use
48
+ max_tokens: Maximum tokens to include (if specified)
49
+ model: Model to use for token counting
50
+ include_parent_context: Whether to include context from parent sessions
51
+ current_query: Current user query for relevance-based context selection
52
+
53
+ Returns:
54
+ A list of message dictionaries suitable for LLM API calls
55
+ """
56
+ if not session.events:
57
+ return []
58
+
59
+ # Convert string strategy to enum if needed
60
+ if isinstance(strategy, str):
61
+ try:
62
+ strategy = PromptStrategy(strategy)
63
+ except ValueError:
64
+ logger.warning(f"Unknown strategy '{strategy}', falling back to MINIMAL")
65
+ strategy = PromptStrategy.MINIMAL
66
+
67
+ # Use the appropriate strategy
68
+ if strategy == PromptStrategy.MINIMAL:
69
+ return await _build_minimal_prompt(session)
70
+ elif strategy == PromptStrategy.TASK_FOCUSED:
71
+ return await _build_task_focused_prompt(session)
72
+ elif strategy == PromptStrategy.TOOL_FOCUSED:
73
+ return await _build_tool_focused_prompt(session)
74
+ elif strategy == PromptStrategy.CONVERSATION:
75
+ return await _build_conversation_prompt(session, max_history=5)
76
+ elif strategy == PromptStrategy.HIERARCHICAL:
77
+ return await _build_hierarchical_prompt(session, include_parent_context)
78
+ else:
79
+ # Default to minimal
80
+ return await _build_minimal_prompt(session)
81
+
82
+
83
+ async def _build_minimal_prompt(session: Session) -> List[Dict[str, str]]:
84
+ """
85
+ Build a minimal prompt from a session.
86
+
87
+ This follows the original implementation's approach:
88
+ - Include the first USER message (task)
89
+ - Include the latest assistant MESSAGE with content set to None
90
+ - Include TOOL_CALL children as tool role messages
91
+ - Fall back to SUMMARY retry note if no TOOL_CALL children exist
92
+ """
93
+ # First USER message
94
+ first_user = next(
95
+ (
96
+ e
97
+ for e in session.events
98
+ if e.type == EventType.MESSAGE and e.source == EventSource.USER
99
+ ),
100
+ None,
101
+ )
102
+
103
+ # Latest assistant MESSAGE
104
+ assistant_msg = next(
105
+ (
106
+ ev
107
+ for ev in reversed(session.events)
108
+ if ev.type == EventType.MESSAGE and ev.source != EventSource.USER
109
+ ),
110
+ None,
111
+ )
112
+
113
+ if assistant_msg is None:
114
+ # Only the user message exists so far
115
+ return [{"role": "user", "content": first_user.message}] if first_user else []
116
+
117
+ # Children of that assistant
118
+ children = [
119
+ e
120
+ for e in session.events
121
+ if e.metadata.get("parent_event_id") == assistant_msg.id
122
+ ]
123
+ tool_calls = [c for c in children if c.type == EventType.TOOL_CALL]
124
+ summaries = [c for c in children if c.type == EventType.SUMMARY]
125
+
126
+ # Assemble prompt
127
+ prompt: List[Dict[str, str]] = []
128
+ if first_user:
129
+ # Handle both string messages and dict messages
130
+ user_content = first_user.message
131
+ if isinstance(user_content, dict) and "content" in user_content:
132
+ user_content = user_content["content"]
133
+ prompt.append({"role": "user", "content": user_content})
134
+
135
+ # ALWAYS add the assistant marker - but strip its free text
136
+ prompt.append({"role": "assistant", "content": None})
137
+
138
+ if tool_calls:
139
+ for tc in tool_calls:
140
+ # Extract relevant information from the tool call
141
+ # Handle both new and legacy formats
142
+ if isinstance(tc.message, dict):
143
+ tool_name = tc.message.get("tool_name", tc.message.get("tool", "unknown"))
144
+ tool_result = tc.message.get("result", {})
145
+ else:
146
+ # Legacy format or unexpected type
147
+ tool_name = "unknown"
148
+ tool_result = tc.message
149
+
150
+ prompt.append(
151
+ {
152
+ "role": "tool",
153
+ "name": tool_name,
154
+ "content": json.dumps(tool_result, default=str),
155
+ }
156
+ )
157
+ elif summaries:
158
+ # Use the latest summary
159
+ summary = summaries[-1]
160
+ if isinstance(summary.message, dict) and "note" in summary.message:
161
+ prompt.append({"role": "system", "content": summary.message["note"]})
162
+ else:
163
+ # Handle legacy or unexpected format
164
+ prompt.append({"role": "system", "content": str(summary.message)})
165
+
166
+ return prompt
167
+
168
+
169
+ async def _build_task_focused_prompt(session: Session) -> List[Dict[str, str]]:
170
+ """
171
+ Build a task-focused prompt.
172
+
173
+ This strategy emphasizes the original task and latest context:
174
+ - Includes the first USER message as the main task
175
+ - Includes the most recent USER message for current context
176
+ - Includes only the most recent and successful tool results
177
+ """
178
+ # Get first and most recent user messages
179
+ user_messages = [
180
+ e for e in session.events
181
+ if e.type == EventType.MESSAGE and e.source == EventSource.USER
182
+ ]
183
+
184
+ if not user_messages:
185
+ return []
186
+
187
+ first_user = user_messages[0]
188
+ latest_user = user_messages[-1] if len(user_messages) > 1 else None
189
+
190
+ # Latest assistant MESSAGE
191
+ assistant_msg = next(
192
+ (
193
+ ev
194
+ for ev in reversed(session.events)
195
+ if ev.type == EventType.MESSAGE and ev.source != EventSource.USER
196
+ ),
197
+ None,
198
+ )
199
+
200
+ # Build prompt
201
+ prompt = []
202
+
203
+ # Always include the first user message (the main task)
204
+ first_content = first_user.message
205
+ if isinstance(first_content, dict) and "content" in first_content:
206
+ first_content = first_content["content"]
207
+ prompt.append({"role": "user", "content": first_content})
208
+
209
+ # Include the latest user message if different from the first
210
+ if latest_user and latest_user.id != first_user.id:
211
+ latest_content = latest_user.message
212
+ if isinstance(latest_content, dict) and "content" in latest_content:
213
+ latest_content = latest_content["content"]
214
+ prompt.append({"role": "user", "content": latest_content})
215
+
216
+ # Include assistant response placeholder
217
+ if assistant_msg:
218
+ prompt.append({"role": "assistant", "content": None})
219
+
220
+ # Find successful tool calls
221
+ children = [
222
+ e for e in session.events
223
+ if e.metadata.get("parent_event_id") == assistant_msg.id
224
+ ]
225
+ tool_calls = [c for c in children if c.type == EventType.TOOL_CALL]
226
+
227
+ # Only include successful tool results
228
+ for tc in tool_calls:
229
+ # Extract and check if result indicates success
230
+ if isinstance(tc.message, dict):
231
+ tool_name = tc.message.get("tool_name", tc.message.get("tool", "unknown"))
232
+ tool_result = tc.message.get("result", {})
233
+
234
+ # Skip error results
235
+ if isinstance(tool_result, dict) and tool_result.get("status") == "error":
236
+ continue
237
+
238
+ prompt.append({
239
+ "role": "tool",
240
+ "name": tool_name,
241
+ "content": json.dumps(tool_result, default=str),
242
+ })
243
+
244
+ return prompt
245
+
246
+
247
+ async def _build_tool_focused_prompt(session: Session) -> List[Dict[str, str]]:
248
+ """
249
+ Build a tool-focused prompt.
250
+
251
+ This strategy emphasizes tool usage:
252
+ - Includes the latest user query
253
+ - Includes detailed information about tool calls and results
254
+ - Includes error information from failed tool calls
255
+ """
256
+ # Get the latest user message
257
+ latest_user = next(
258
+ (e for e in reversed(session.events)
259
+ if e.type == EventType.MESSAGE and e.source == EventSource.USER),
260
+ None
261
+ )
262
+
263
+ if not latest_user:
264
+ return []
265
+
266
+ # Get the latest assistant message
267
+ assistant_msg = next(
268
+ (ev for ev in reversed(session.events)
269
+ if ev.type == EventType.MESSAGE and ev.source != EventSource.USER),
270
+ None
271
+ )
272
+
273
+ # Build prompt
274
+ prompt = []
275
+
276
+ # Include user message
277
+ user_content = latest_user.message
278
+ if isinstance(user_content, dict) and "content" in user_content:
279
+ user_content = user_content["content"]
280
+ prompt.append({"role": "user", "content": user_content})
281
+
282
+ # Include assistant placeholder
283
+ if assistant_msg:
284
+ prompt.append({"role": "assistant", "content": None})
285
+
286
+ # Get all tool calls for this assistant
287
+ children = [
288
+ e for e in session.events
289
+ if e.metadata.get("parent_event_id") == assistant_msg.id
290
+ ]
291
+ tool_calls = [c for c in children if c.type == EventType.TOOL_CALL]
292
+
293
+ # Add all tool calls with status information
294
+ for tc in tool_calls:
295
+ if isinstance(tc.message, dict):
296
+ tool_name = tc.message.get("tool_name", tc.message.get("tool", "unknown"))
297
+ tool_result = tc.message.get("result", {})
298
+ error = tc.message.get("error", None)
299
+
300
+ # Include status information in the tool response
301
+ content = tool_result
302
+ if error:
303
+ content = {"error": error, "details": tool_result}
304
+
305
+ prompt.append({
306
+ "role": "tool",
307
+ "name": tool_name,
308
+ "content": json.dumps(content, default=str),
309
+ })
310
+
311
+ return prompt
312
+
313
+
314
+ async def _build_conversation_prompt(
315
+ session: Session,
316
+ max_history: int = 5
317
+ ) -> List[Dict[str, str]]:
318
+ """
319
+ Build a conversation-style prompt with recent history.
320
+
321
+ This strategy creates a more natural conversation:
322
+ - Includes up to max_history recent messages in order
323
+ - Preserves conversation flow
324
+ - Still handles tool calls appropriately
325
+ """
326
+ # Get relevant message events
327
+ message_events = [
328
+ e for e in session.events
329
+ if e.type == EventType.MESSAGE
330
+ ]
331
+
332
+ # Take the most recent messages
333
+ recent_messages = message_events[-max_history:] if len(message_events) > max_history else message_events
334
+
335
+ # Build the conversation history
336
+ prompt = []
337
+ for msg in recent_messages:
338
+ role = "user" if msg.source == EventSource.USER else "assistant"
339
+ content = msg.message
340
+
341
+ # Handle different message formats
342
+ if isinstance(content, dict) and "content" in content:
343
+ content = content["content"]
344
+
345
+ # For the last assistant message, set content to None
346
+ if role == "assistant" and msg == recent_messages[-1] and msg.source != EventSource.USER:
347
+ content = None
348
+
349
+ # Add tool call results for this assistant message
350
+ tool_calls = [
351
+ e for e in session.events
352
+ if e.type == EventType.TOOL_CALL and e.metadata.get("parent_event_id") == msg.id
353
+ ]
354
+
355
+ # Add the message first, then tools
356
+ prompt.append({"role": role, "content": content})
357
+
358
+ # Add tool results
359
+ for tc in tool_calls:
360
+ if isinstance(tc.message, dict):
361
+ tool_name = tc.message.get("tool_name", tc.message.get("tool", "unknown"))
362
+ tool_result = tc.message.get("result", {})
363
+
364
+ prompt.append({
365
+ "role": "tool",
366
+ "name": tool_name,
367
+ "content": json.dumps(tool_result, default=str),
368
+ })
369
+
370
+ # Skip adding this message again
371
+ continue
372
+
373
+ prompt.append({"role": role, "content": content})
374
+
375
+ return prompt
376
+
377
+
378
+ async def _build_hierarchical_prompt(
379
+ session: Session,
380
+ include_parent_context: bool = True
381
+ ) -> List[Dict[str, str]]:
382
+ """
383
+ Build a prompt that includes hierarchical context.
384
+
385
+ This strategy leverages the session hierarchy:
386
+ - Starts with the minimal prompt
387
+ - Includes summaries from parent sessions if available
388
+ """
389
+ # Start with the minimal prompt
390
+ prompt = await _build_minimal_prompt(session)
391
+
392
+ # If parent context is enabled and session has a parent
393
+ if include_parent_context and session.parent_id:
394
+ store = SessionStoreProvider.get_store()
395
+ parent = await store.get(session.parent_id)
396
+
397
+ if parent:
398
+ # Find the most recent summary in parent
399
+ summary_event = next(
400
+ (e for e in reversed(parent.events)
401
+ if e.type == EventType.SUMMARY),
402
+ None
403
+ )
404
+
405
+ if summary_event:
406
+ # Extract summary content
407
+ summary_content = summary_event.message
408
+ if isinstance(summary_content, dict) and "note" in summary_content:
409
+ summary_content = summary_content["note"]
410
+ elif isinstance(summary_content, dict) and "content" in summary_content:
411
+ summary_content = summary_content["content"]
412
+
413
+ # Add parent context at the beginning
414
+ prompt.insert(0, {
415
+ "role": "system",
416
+ "content": f"Context from previous conversation: {summary_content}"
417
+ })
418
+
419
+ return prompt
420
+
421
+ async def truncate_prompt_to_token_limit(
422
+ prompt: List[Dict[str, str]],
423
+ max_tokens: int,
424
+ model: str = "gpt-3.5-turbo",
425
+ ) -> List[Dict[str, str]]:
426
+ """
427
+ Trim a prompt so its total token count is ≤ `max_tokens`.
428
+
429
+ Strategy:
430
+ • If already within limit → return unchanged
431
+ • Otherwise keep:
432
+ - the very first user message
433
+ - everything from the last assistant message onward
434
+ - (optionally) one tool message so the model still sees a result
435
+ """
436
+ if not prompt:
437
+ return []
438
+
439
+ # ------------------------------------------------------------------ #
440
+ # quick overall count
441
+ text = "\n".join(f"{m.get('role', 'unknown')}: {m.get('content') or ''}" for m in prompt)
442
+ total = TokenUsage.count_tokens(text, model)
443
+ total = await total if asyncio.iscoroutine(total) else total
444
+ if total <= max_tokens:
445
+ return prompt
446
+
447
+ # ------------------------------------------------------------------ #
448
+ # decide which messages to keep
449
+ first_user_idx = next((i for i, m in enumerate(prompt) if m["role"] == "user"), None)
450
+ last_asst_idx = next(
451
+ (len(prompt) - 1 - i for i, m in enumerate(reversed(prompt)) if m["role"] == "assistant"),
452
+ None,
453
+ )
454
+
455
+ kept: List[Dict[str, str]] = []
456
+ if first_user_idx is not None:
457
+ kept.append(prompt[first_user_idx])
458
+ if last_asst_idx is not None:
459
+ kept.extend(prompt[last_asst_idx:])
460
+
461
+ # ------------------------------------------------------------------ #
462
+ # re-count and maybe drop / add tool messages
463
+ remaining = TokenUsage.count_tokens(str(kept), model)
464
+ remaining = await remaining if asyncio.iscoroutine(remaining) else remaining
465
+
466
+ if remaining > max_tokens:
467
+ # remove any tool messages we just added
468
+ kept = [m for m in kept if m["role"] != "tool"]
469
+ # but guarantee at least one tool message (the first) if it’ll fit
470
+ first_tool = next((m for m in prompt if m["role"] == "tool"), None)
471
+ if first_tool:
472
+ kept.append(first_tool)
473
+
474
+ return kept
@@ -0,0 +1,44 @@
1
+ # chuk_ai_session_manager/storage/__init__.py
2
+ """
3
+ Storage module for the chuk session manager.
4
+ """
5
+ # Import base components first to avoid circular imports
6
+ try:
7
+ from chuk_ai_session_manager.storage.base import SessionStoreInterface, SessionStoreProvider
8
+ except ImportError:
9
+ pass
10
+
11
+ # Try to import providers if available
12
+ try:
13
+ from chuk_ai_session_manager.storage.providers.memory import InMemorySessionStore
14
+ except ImportError:
15
+ pass
16
+
17
+ try:
18
+ from chuk_ai_session_manager.storage.providers.file import FileSessionStore, create_file_session_store
19
+ except ImportError:
20
+ pass
21
+
22
+ # Try to import Redis - this is optional
23
+ try:
24
+ from chuk_ai_session_manager.storage.providers.redis import RedisSessionStore, create_redis_session_store
25
+ _has_redis = True
26
+ except ImportError:
27
+ _has_redis = False
28
+
29
+ # Define __all__ based on what was successfully imported
30
+ __all__ = []
31
+
32
+ # Basic components
33
+ for name in ['SessionStoreInterface', 'SessionStoreProvider', 'InMemorySessionStore']:
34
+ if name in globals():
35
+ __all__.append(name)
36
+
37
+ # File store
38
+ for name in ['FileSessionStore', 'create_file_session_store']:
39
+ if name in globals():
40
+ __all__.append(name)
41
+
42
+ # Redis store (optional)
43
+ if _has_redis:
44
+ __all__.extend(['RedisSessionStore', 'create_redis_session_store'])
@@ -0,0 +1,50 @@
1
+ # chuk_ai_session_manager/storage/base.py
2
+ """
3
+ Base interfaces and providers for async session storage.
4
+ """
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any, Dict, List, Optional, TypeVar
7
+
8
+ T = TypeVar('T')
9
+
10
+ class SessionStoreInterface(ABC):
11
+ """Interface for pluggable async session stores."""
12
+
13
+ @abstractmethod
14
+ async def get(self, session_id: str) -> Optional[Any]:
15
+ """Retrieve a session by its ID, or None if not found."""
16
+ ...
17
+
18
+ @abstractmethod
19
+ async def save(self, session: Any) -> None:
20
+ """Save or update a session object in the store."""
21
+ ...
22
+
23
+ @abstractmethod
24
+ async def delete(self, session_id: str) -> None:
25
+ """Delete a session by its ID."""
26
+ ...
27
+
28
+ @abstractmethod
29
+ async def list_sessions(self, prefix: str = "") -> List[str]:
30
+ """List all session IDs, optionally filtered by prefix."""
31
+ ...
32
+
33
+
34
+ class SessionStoreProvider:
35
+ """Provider for a globally-shared async session store."""
36
+ _store: Optional[SessionStoreInterface] = None
37
+
38
+ @classmethod
39
+ def get_store(cls) -> SessionStoreInterface:
40
+ """Get the currently configured session store."""
41
+ if cls._store is None:
42
+ # Defer import to avoid circular imports
43
+ from chuk_ai_session_manager.storage.providers.memory import InMemorySessionStore
44
+ cls._store = InMemorySessionStore()
45
+ return cls._store
46
+
47
+ @classmethod
48
+ def set_store(cls, store: SessionStoreInterface) -> None:
49
+ """Set a new session store implementation."""
50
+ cls._store = store
File without changes