zrb 1.9.16__py3-none-any.whl → 1.10.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.
@@ -0,0 +1,438 @@
1
+ import json
2
+ import os
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ from zrb.config.config import CFG
7
+ from zrb.context.any_context import AnyContext
8
+ from zrb.task.llm.typing import ListOfDict
9
+ from zrb.util.file import read_file, read_file_with_line_numbers, write_file
10
+ from zrb.util.run import run_async
11
+
12
+
13
+ class ConversationHistory:
14
+
15
+ def __init__(
16
+ self,
17
+ past_conversation_summary: str = "",
18
+ past_conversation_transcript: str = "",
19
+ history: ListOfDict | None = None,
20
+ contextual_note: str | None = None,
21
+ long_term_note: str | None = None,
22
+ project_path: str | None = None,
23
+ ):
24
+ self.past_conversation_transcript = past_conversation_transcript
25
+ self.past_conversation_summary = past_conversation_summary
26
+ self.history = history if history is not None else []
27
+ self.contextual_note = contextual_note if contextual_note is not None else ""
28
+ self.long_term_note = long_term_note if long_term_note is not None else ""
29
+ self.project_path = project_path if project_path is not None else os.getcwd()
30
+
31
+ def to_dict(self) -> dict[str, Any]:
32
+ return {
33
+ "past_conversation_summary": self.past_conversation_summary,
34
+ "past_conversation_transcript": self.past_conversation_transcript,
35
+ "history": self.history,
36
+ "contextual_note": self.contextual_note,
37
+ "long_term_note": self.long_term_note,
38
+ }
39
+
40
+ def model_dump_json(self, indent: int = 2) -> str:
41
+ return json.dumps(self.to_dict(), indent=indent)
42
+
43
+ @classmethod
44
+ async def read_from_source(
45
+ cls,
46
+ ctx: AnyContext,
47
+ reader: Callable[[AnyContext], dict[str, Any] | list | None] | None,
48
+ file_path: str | None,
49
+ ) -> "ConversationHistory | None":
50
+ # Priority 1: Reader function
51
+ if reader:
52
+ try:
53
+ raw_data = await run_async(reader(ctx))
54
+ if raw_data:
55
+ instance = cls.parse_and_validate(ctx, raw_data, "reader")
56
+ if instance:
57
+ return instance
58
+ except Exception as e:
59
+ ctx.log_warning(
60
+ f"Error executing conversation history reader: {e}. Ignoring."
61
+ )
62
+ # Priority 2: History file
63
+ if file_path and os.path.isfile(file_path):
64
+ try:
65
+ content = read_file(file_path)
66
+ raw_data = json.loads(content)
67
+ instance = cls.parse_and_validate(ctx, raw_data, f"file '{file_path}'")
68
+ if instance:
69
+ return instance
70
+ except json.JSONDecodeError:
71
+ ctx.log_warning(
72
+ f"Could not decode JSON from history file '{file_path}'. "
73
+ "Ignoring file content."
74
+ )
75
+ except Exception as e:
76
+ ctx.log_warning(
77
+ f"Error reading history file '{file_path}': {e}. "
78
+ "Ignoring file content."
79
+ )
80
+ # Fallback: Return default value
81
+ return None
82
+
83
+ def fetch_newest_notes(self):
84
+ long_term_note_path = self._get_long_term_note_path()
85
+ if os.path.isfile(long_term_note_path):
86
+ self.long_term_note = read_file(long_term_note_path)
87
+ contextual_note_path = self._get_contextual_note_path()
88
+ if os.path.isfile(contextual_note_path):
89
+ self.contextual_note = read_file(contextual_note_path)
90
+
91
+ @classmethod
92
+ def parse_and_validate(
93
+ cls, ctx: AnyContext, data: Any, source: str
94
+ ) -> "ConversationHistory":
95
+ try:
96
+ if isinstance(data, cls):
97
+ return data # Already a valid instance
98
+ if isinstance(data, dict):
99
+ # This handles both the new format and the old {'context': ..., 'history': ...}
100
+ return cls(
101
+ past_conversation_summary=data.get("past_conversation_summary", ""),
102
+ past_conversation_transcript=data.get(
103
+ "past_conversation_transcript", ""
104
+ ),
105
+ history=data.get("history", data.get("messages", [])),
106
+ contextual_note=data.get("contextual_note", ""),
107
+ long_term_note=data.get("long_term_note", ""),
108
+ )
109
+ elif isinstance(data, list):
110
+ # Handle very old format (just a list) - wrap it
111
+ ctx.log_warning(
112
+ f"History from {source} contains legacy list format. "
113
+ "Wrapping it into the new structure. "
114
+ "Consider updating the source format."
115
+ )
116
+ return cls(history=data)
117
+ else:
118
+ ctx.log_warning(
119
+ f"History data from {source} has unexpected format "
120
+ f"(type: {type(data)}). Ignoring."
121
+ )
122
+ except Exception as e: # Catch validation errors too
123
+ ctx.log_warning(
124
+ f"Error validating/parsing history data from {source}: {e}. Ignoring."
125
+ )
126
+ return cls()
127
+
128
+ def write_past_conversation_summary(self, past_conversation_summary: str):
129
+ """
130
+ Write or update the past conversation summary.
131
+
132
+ Use this tool to store or update a summary of previous conversations for
133
+ future reference. This is useful for providing context to LLMs or other tools
134
+ that need a concise history.
135
+
136
+ Args:
137
+ past_conversation_summary (str): The summary text to store.
138
+
139
+ Returns:
140
+ None
141
+
142
+ Raises:
143
+ Exception: If the summary cannot be written.
144
+ """
145
+ self.past_conversation_summary = past_conversation_summary
146
+
147
+ def write_past_conversation_transcript(self, past_conversation_transcript: str):
148
+ """
149
+ Write or update the past conversation transcript.
150
+
151
+ Use this tool to store or update the full transcript of previous conversations.
152
+ This is useful for providing detailed context to LLMs or for record-keeping.
153
+
154
+ Args:
155
+ past_conversation_transcript (str): The transcript text to store.
156
+
157
+ Returns:
158
+ None
159
+
160
+ Raises:
161
+ Exception: If the transcript cannot be written.
162
+ """
163
+ self.past_conversation_transcript = past_conversation_transcript
164
+
165
+ def read_long_term_note(
166
+ self,
167
+ start_line: int | None = None,
168
+ end_line: int | None = None,
169
+ ) -> str:
170
+ """
171
+ Read the content of the long-term note, optionally for a specific line range.
172
+
173
+ This tool helps you retrieve knowledge or notes stored for long-term reference.
174
+ If the note does not exist, you may want to create it using the write tool.
175
+
176
+ Args:
177
+ start_line (int, optional): 1-based line number to start reading from.
178
+ end_line (int, optional): 1-based line number to stop reading at (inclusive).
179
+
180
+ Returns:
181
+ str: JSON with file path, content (with line numbers), start/end lines,
182
+ and total lines.
183
+
184
+ Raises:
185
+ Exception: If the note cannot be read.
186
+ Suggests writing the note if it does not exist.
187
+ """
188
+ return self._read_note(
189
+ self._get_long_term_note_path(),
190
+ start_line,
191
+ end_line,
192
+ note_type="long-term note",
193
+ )
194
+
195
+ def write_long_term_note(self, content: str) -> str:
196
+ """
197
+ Write or overwrite the content of the long-term note.
198
+
199
+ Use this tool to create a new long-term note or replace its entire content.
200
+ Always read the note first to avoid accidental data loss, unless you are sure
201
+ you want to overwrite.
202
+
203
+ Args:
204
+ content (str): The full content to write to the note.
205
+
206
+ Returns:
207
+ str: JSON indicating success and the note path.
208
+
209
+ Raises:
210
+ Exception: If the note cannot be written. Suggests checking permissions or path.
211
+ """
212
+ self.long_term_note = content
213
+ return self._write_note(
214
+ self._get_long_term_note_path(), content, note_type="long-term note"
215
+ )
216
+
217
+ def replace_in_long_term_note(
218
+ self,
219
+ old_string: str,
220
+ new_string: str,
221
+ ) -> str:
222
+ """
223
+ Replace the first occurrence of a string in the long-term note.
224
+
225
+ Use this tool to update a specific part of the long-term note without
226
+ overwriting the entire content. If the note does not exist, consider writing it
227
+ first. If the string is not found, check your input or read the note to verify.
228
+
229
+ Args:
230
+ old_string (str): The exact string to search for and replace.
231
+ new_string (str): The string to replace with.
232
+
233
+ Returns:
234
+ str: JSON indicating success and the note path.
235
+
236
+ Raises:
237
+ Exception: If the note does not exist or the string is not found.
238
+ Suggests writing or reading the note.
239
+ """
240
+ result = self._replace_in_note(
241
+ self._get_long_term_note_path(),
242
+ old_string,
243
+ new_string,
244
+ note_type="long-term note",
245
+ )
246
+ self.long_term_note = new_string
247
+ return result
248
+
249
+ def read_contextual_note(
250
+ self,
251
+ start_line: int | None = None,
252
+ end_line: int | None = None,
253
+ ) -> str:
254
+ """
255
+ Read the content of the contextual note, optionally for a specific line range.
256
+
257
+ This tool helps you retrieve project-specific or session-specific notes.
258
+ If the note does not exist, you may want to create it using the write tool.
259
+
260
+ Args:
261
+ start_line (int, optional): 1-based line number to start reading from.
262
+ end_line (int, optional): 1-based line number to stop reading at (inclusive).
263
+
264
+ Returns:
265
+ str: JSON with file path, content (with line numbers), start/end lines,
266
+ and total lines.
267
+
268
+ Raises:
269
+ Exception: If the note cannot be read.
270
+ Suggests writing the note if it does not exist.
271
+ """
272
+ return self._read_note(
273
+ self._get_contextual_note_path(),
274
+ start_line,
275
+ end_line,
276
+ note_type="contextual note",
277
+ )
278
+
279
+ def write_contextual_note(self, content: str) -> str:
280
+ """
281
+ Write or overwrite the content of the contextual note.
282
+
283
+ Use this tool to create a new contextual note or replace its entire content.
284
+ Always read the note first to avoid accidental data loss, unless you are sure
285
+ you want to overwrite.
286
+
287
+ Args:
288
+ content (str): The full content to write to the note.
289
+
290
+ Returns:
291
+ str: JSON indicating success and the note path.
292
+
293
+ Raises:
294
+ Exception: If the note cannot be written. Suggests checking permissions or path.
295
+ """
296
+ self.contextual_note = content
297
+ return self._write_note(
298
+ self._get_contextual_note_path(), content, note_type="contextual note"
299
+ )
300
+
301
+ def replace_in_contextual_note(
302
+ self,
303
+ old_string: str,
304
+ new_string: str,
305
+ ) -> str:
306
+ """
307
+ Replace the first occurrence of a string in the contextual note.
308
+
309
+ Use this tool to update a specific part of the contextual note without
310
+ overwriting the entire content. If the note does not exist, consider writing it
311
+ first. If the string is not found, check your input or read the note to verify.
312
+
313
+ Args:
314
+ old_string (str): The exact string to search for and replace.
315
+ new_string (str): The string to replace with.
316
+
317
+ Returns:
318
+ str: JSON indicating success and the note path.
319
+
320
+ Raises:
321
+ Exception: If the note does not exist or the string is not found.
322
+ Suggests writing or reading the note.
323
+ """
324
+ result = self._replace_in_note(
325
+ self._get_contextual_note_path(),
326
+ old_string,
327
+ new_string,
328
+ note_type="contextual note",
329
+ )
330
+ self.contextual_note = new_string
331
+ return result
332
+
333
+ def _get_long_term_note_path(self) -> str:
334
+ return os.path.abspath(os.path.expanduser(CFG.LLM_LONG_TERM_NOTE_PATH))
335
+
336
+ def _get_contextual_note_path(self) -> str:
337
+ return os.path.join(self.project_path, CFG.LLM_CONTEXTUAL_NOTE_FILE)
338
+
339
+ def _read_note(
340
+ self,
341
+ path: str,
342
+ start_line: int | None = None,
343
+ end_line: int | None = None,
344
+ note_type: str = "note",
345
+ ) -> str:
346
+ """
347
+ Internal helper to read a note file with line numbers and error handling.
348
+ """
349
+ if not os.path.exists(path):
350
+ return json.dumps(
351
+ {
352
+ "path": path,
353
+ "content": "",
354
+ "start_line": 0,
355
+ "end_line": 0,
356
+ "total_lines": 0,
357
+ }
358
+ )
359
+ try:
360
+ content = read_file_with_line_numbers(path)
361
+ lines = content.splitlines()
362
+ total_lines = len(lines)
363
+ start_idx = (start_line - 1) if start_line is not None else 0
364
+ end_idx = end_line if end_line is not None else total_lines
365
+ if start_idx < 0:
366
+ start_idx = 0
367
+ if end_idx > total_lines:
368
+ end_idx = total_lines
369
+ if start_idx > end_idx:
370
+ start_idx = end_idx
371
+ selected_lines = lines[start_idx:end_idx]
372
+ content_result = "\n".join(selected_lines)
373
+ return json.dumps(
374
+ {
375
+ "path": path,
376
+ "content": content_result,
377
+ "start_line": start_idx + 1,
378
+ "end_line": end_idx,
379
+ "total_lines": total_lines,
380
+ }
381
+ )
382
+ except Exception:
383
+ raise Exception(
384
+ f"Failed to read the {note_type}. "
385
+ f"If the {note_type} does not exist, try writing it first."
386
+ )
387
+
388
+ def _write_note(self, path: str, content: str, note_type: str = "note") -> str:
389
+ """
390
+ Internal helper to write a note file with error handling.
391
+ """
392
+ try:
393
+ directory = os.path.dirname(path)
394
+ if directory and not os.path.exists(directory):
395
+ os.makedirs(directory, exist_ok=True)
396
+ write_file(path, content)
397
+ return json.dumps({"success": True, "path": path})
398
+ except (OSError, IOError):
399
+ raise Exception(
400
+ f"Failed to write the {note_type}. "
401
+ "Please check if the path is correct and you have write permissions."
402
+ )
403
+ except Exception:
404
+ raise Exception(
405
+ f"Unexpected error while writing the {note_type}. "
406
+ "Please check your input and try again."
407
+ )
408
+
409
+ def _replace_in_note(
410
+ self, path: str, old_string: str, new_string: str, note_type: str = "note"
411
+ ) -> str:
412
+ """
413
+ Internal helper to replace a string in a note file with error handling.
414
+ """
415
+ if not os.path.exists(path):
416
+ raise Exception(
417
+ (
418
+ f"{note_type.capitalize()} not found. "
419
+ f"Consider writing a new {note_type} first."
420
+ )
421
+ )
422
+ try:
423
+ content = read_file(path)
424
+ if old_string not in content:
425
+ raise Exception(
426
+ f"The specified string to replace was not found in the {note_type}. ("
427
+ f"Try reading the {note_type} to verify its content or "
428
+ f"write a new one if needed)."
429
+ )
430
+ new_content = content.replace(old_string, new_string, 1)
431
+ write_file(path, new_content)
432
+ return json.dumps({"success": True, "path": path})
433
+ except Exception:
434
+ raise Exception(
435
+ f"Failed to replace content in the {note_type}. ("
436
+ f"Try reading the {note_type} to verify its content or "
437
+ "write a new one if needed)."
438
+ )
@@ -7,13 +7,15 @@ from zrb.config.llm_config import llm_config
7
7
  from zrb.config.llm_rate_limitter import LLMRateLimiter, llm_rate_limitter
8
8
  from zrb.context.any_context import AnyContext
9
9
  from zrb.task.llm.agent import run_agent_iteration
10
- from zrb.task.llm.history import (
10
+ from zrb.task.llm.conversation_history import (
11
11
  count_part_in_history_list,
12
- replace_system_prompt_in_history_list,
12
+ replace_system_prompt_in_history,
13
13
  )
14
+ from zrb.task.llm.conversation_history_model import ConversationHistory
14
15
  from zrb.task.llm.typing import ListOfDict
15
16
  from zrb.util.attr import get_bool_attr, get_int_attr
16
17
  from zrb.util.cli.style import stylize_faint
18
+ from zrb.util.llm.prompt import make_prompt_section
17
19
 
18
20
  if TYPE_CHECKING:
19
21
  from pydantic_ai.models import Model
@@ -82,9 +84,8 @@ async def summarize_history(
82
84
  ctx: AnyContext,
83
85
  model: "Model | str | None",
84
86
  settings: "ModelSettings | None",
85
- prompt: str,
86
- previous_summary: str,
87
- history_list: ListOfDict,
87
+ system_prompt: str,
88
+ conversation_history: ConversationHistory,
88
89
  rate_limitter: LLMRateLimiter | None = None,
89
90
  retries: int = 3,
90
91
  ) -> str:
@@ -93,16 +94,65 @@ async def summarize_history(
93
94
 
94
95
  ctx.log_info("Attempting to summarize conversation history...")
95
96
  # Construct the user prompt for the summarization agent
96
- user_prompt = json.dumps(
97
- {"previous_summary": previous_summary, "recent_history": history_list}
97
+ user_prompt = "\n".join(
98
+ [
99
+ make_prompt_section(
100
+ "Past Conversation",
101
+ "\n".join(
102
+ [
103
+ make_prompt_section(
104
+ "Summary",
105
+ conversation_history.past_conversation_summary,
106
+ as_code=True,
107
+ ),
108
+ make_prompt_section(
109
+ "Last Transcript",
110
+ conversation_history.past_conversation_transcript,
111
+ as_code=True,
112
+ ),
113
+ ]
114
+ ),
115
+ ),
116
+ make_prompt_section(
117
+ "Recent Conversation (JSON)",
118
+ json.dumps(conversation_history.history),
119
+ as_code=True,
120
+ ),
121
+ make_prompt_section(
122
+ "Notes",
123
+ "\n".join(
124
+ [
125
+ make_prompt_section(
126
+ "Long Term",
127
+ conversation_history.long_term_note,
128
+ as_code=True,
129
+ ),
130
+ make_prompt_section(
131
+ "Contextual",
132
+ conversation_history.contextual_note,
133
+ as_code=True,
134
+ ),
135
+ ]
136
+ ),
137
+ ),
138
+ ]
98
139
  )
99
140
  summarization_agent = Agent(
100
141
  model=model,
101
- system_prompt=prompt,
142
+ system_prompt=system_prompt,
102
143
  model_settings=settings,
103
144
  retries=retries,
145
+ tools=[
146
+ conversation_history.write_past_conversation_summary,
147
+ conversation_history.write_past_conversation_transcript,
148
+ conversation_history.read_contextual_note,
149
+ conversation_history.write_contextual_note,
150
+ conversation_history.replace_in_contextual_note,
151
+ conversation_history.read_long_term_note,
152
+ conversation_history.write_long_term_note,
153
+ conversation_history.replace_in_long_term_note,
154
+ ],
104
155
  )
105
-
106
156
  try:
107
157
  ctx.print(stylize_faint("📝 Summarize"), plain=True)
108
158
  summary_run = await run_agent_iteration(
@@ -113,27 +163,22 @@ async def summarize_history(
113
163
  rate_limitter=rate_limitter,
114
164
  )
115
165
  if summary_run and summary_run.result and summary_run.result.output:
116
- new_summary = str(summary_run.result.output)
117
166
  usage = summary_run.result.usage()
118
167
  ctx.print(stylize_faint(f"📝 Summarization Token: {usage}"), plain=True)
119
168
  ctx.print(plain=True)
120
169
  ctx.log_info("History summarized and updated.")
121
- ctx.log_info(f"New conversation summary:\n{new_summary}")
122
- return new_summary
123
170
  else:
124
171
  ctx.log_warning("History summarization failed or returned no data.")
125
172
  except BaseException as e:
126
173
  ctx.log_warning(f"Error during history summarization: {e}")
127
174
  traceback.print_exc()
128
-
129
175
  # Return the original summary if summarization fails
130
- return previous_summary
176
+ return conversation_history
131
177
 
132
178
 
133
179
  async def maybe_summarize_history(
134
180
  ctx: AnyContext,
135
- history_list: ListOfDict,
136
- conversation_summary: str,
181
+ conversation_history: ConversationHistory,
137
182
  should_summarize_history_attr: BoolAttr | None,
138
183
  render_summarize_history: bool,
139
184
  history_summarization_token_threshold_attr: IntAttr | None,
@@ -142,26 +187,31 @@ async def maybe_summarize_history(
142
187
  model_settings: "ModelSettings | None",
143
188
  summarization_prompt: str,
144
189
  rate_limitter: LLMRateLimiter | None = None,
145
- ) -> tuple[ListOfDict, str]:
190
+ ) -> ConversationHistory:
146
191
  """Summarizes history and updates context if enabled and threshold met."""
147
- shorten_history_list = replace_system_prompt_in_history_list(history_list)
192
+ shorten_history = replace_system_prompt_in_history(conversation_history.history)
148
193
  if should_summarize_history(
149
194
  ctx,
150
- shorten_history_list,
195
+ shorten_history,
151
196
  should_summarize_history_attr,
152
197
  render_summarize_history,
153
198
  history_summarization_token_threshold_attr,
154
199
  render_history_summarization_token_threshold,
155
200
  ):
156
- new_summary = await summarize_history(
201
+ original_history = conversation_history.history
202
+ conversation_history.history = shorten_history
203
+ conversation_history = await summarize_history(
157
204
  ctx=ctx,
158
205
  model=model,
159
206
  settings=model_settings,
160
- prompt=summarization_prompt,
161
- previous_summary=conversation_summary,
162
- history_list=shorten_history_list,
207
+ system_prompt=summarization_prompt,
208
+ conversation_history=conversation_history,
163
209
  rate_limitter=rate_limitter,
164
210
  )
165
- # After summarization, the history is cleared and replaced by the new summary
166
- return [], new_summary
167
- return history_list, conversation_summary
211
+ conversation_history.history = original_history
212
+ if (
213
+ conversation_history.past_conversation_summary != ""
214
+ and conversation_history.past_conversation_transcript != ""
215
+ ):
216
+ conversation_history.history = []
217
+ return conversation_history