zrb 1.5.5__py3-none-any.whl → 1.5.7__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.
@@ -1,10 +1,16 @@
1
1
  import json
2
2
  from collections.abc import Callable
3
- from typing import Annotated
3
+
4
+ # Annotated import removed
4
5
 
5
6
 
6
7
  async def open_web_page(url: str) -> str:
7
- """Get content from a web page using a headless browser."""
8
+ """Get parsed text content and links from a web page URL.
9
+ Args:
10
+ url (str): The URL of the web page to open.
11
+ Returns:
12
+ str: JSON: {"content": "parsed text content", "links_on_page": ["url1", ...]}
13
+ """
8
14
 
9
15
  async def get_page_content(page_url: str):
10
16
  user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" # noqa
@@ -53,11 +59,14 @@ async def open_web_page(url: str) -> str:
53
59
 
54
60
 
55
61
  def create_search_internet_tool(serp_api_key: str) -> Callable[[str, int], str]:
56
- def search_internet(
57
- query: Annotated[str, "Search query"],
58
- num_results: Annotated[int, "Search result count, by default 10"] = 10,
59
- ) -> str:
60
- """Search factual information from the internet by using Google."""
62
+ def search_internet(query: str, num_results: int = 10) -> str:
63
+ """Search the internet using SerpApi (Google Search) and return parsed results.
64
+ Args:
65
+ query (str): Search query.
66
+ num_results (int): Search result count. Defaults to 10.
67
+ Returns:
68
+ str: JSON: {"content": "parsed text content", "links_on_page": ["url1", ...]}
69
+ """
61
70
  import requests
62
71
 
63
72
  response = requests.get(
@@ -82,8 +91,13 @@ def create_search_internet_tool(serp_api_key: str) -> Callable[[str, int], str]:
82
91
  return search_internet
83
92
 
84
93
 
85
- def search_wikipedia(query: Annotated[str, "Search query"]) -> str:
86
- """Search on wikipedia"""
94
+ def search_wikipedia(query: str) -> str:
95
+ """Search Wikipedia using its API.
96
+ Args:
97
+ query (str): Search query.
98
+ Returns:
99
+ str: JSON from Wikipedia API: {"batchcomplete": ..., "query": {"search": [...]}}
100
+ """
87
101
  import requests
88
102
 
89
103
  params = {"action": "query", "list": "search", "srsearch": query, "format": "json"}
@@ -91,11 +105,14 @@ def search_wikipedia(query: Annotated[str, "Search query"]) -> str:
91
105
  return response.json()
92
106
 
93
107
 
94
- def search_arxiv(
95
- query: Annotated[str, "Search query"],
96
- num_results: Annotated[int, "Search result count, by default 10"] = 10,
97
- ) -> str:
98
- """Search on Arxiv"""
108
+ def search_arxiv(query: str, num_results: int = 10) -> str:
109
+ """Search ArXiv for papers using its API.
110
+ Args:
111
+ query (str): Search query.
112
+ num_results (int): Search result count. Defaults to 10.
113
+ Returns:
114
+ str: XML string from ArXiv API containing search results.
115
+ """
99
116
  import requests
100
117
 
101
118
  params = {"search_query": f"all:{query}", "start": 0, "max_results": num_results}
@@ -1,35 +1,33 @@
1
- from typing import Dict
2
-
3
1
  from fastapi import HTTPException
4
2
 
5
3
 
6
4
  class NotFoundError(HTTPException):
7
- def __init__(self, message: str, headers: Dict[str, str] | None = None) -> None:
5
+ def __init__(self, message: str, headers: dict[str, str] | None = None) -> None:
8
6
  super().__init__(404, {"message": message}, headers)
9
7
 
10
8
 
11
9
  class ForbiddenError(HTTPException):
12
- def __init__(self, message: str, headers: Dict[str, str] | None = None) -> None:
10
+ def __init__(self, message: str, headers: dict[str, str] | None = None) -> None:
13
11
  super().__init__(403, {"message": message}, headers)
14
12
 
15
13
 
16
14
  class UnauthorizedError(HTTPException):
17
- def __init__(self, message: str, headers: Dict[str, str] | None = None) -> None:
15
+ def __init__(self, message: str, headers: dict[str, str] | None = None) -> None:
18
16
  super().__init__(401, {"message": message}, headers)
19
17
 
20
18
 
21
19
  class InvalidValueError(HTTPException):
22
- def __init__(self, message: str, headers: Dict[str, str] | None = None) -> None:
20
+ def __init__(self, message: str, headers: dict[str, str] | None = None) -> None:
23
21
  super().__init__(422, {"message": message}, headers)
24
22
 
25
23
 
26
24
  class InternalServerError(HTTPException):
27
- def __init__(self, message: str, headers: Dict[str, str] | None = None) -> None:
25
+ def __init__(self, message: str, headers: dict[str, str] | None = None) -> None:
28
26
  super().__init__(500, {"message": message}, headers)
29
27
 
30
28
 
31
29
  class ClientAPIError(HTTPException):
32
30
  def __init__(
33
- self, status_code: int, message: str, headers: Dict[str, str] | None = None
31
+ self, status_code: int, message: str, headers: dict[str, str] | None = None
34
32
  ) -> None:
35
33
  super().__init__(status_code, {"message": message}, headers)
zrb/config.py CHANGED
@@ -31,6 +31,7 @@ def _get_log_level(level: str) -> int:
31
31
  return logging.WARNING
32
32
 
33
33
 
34
+ LOGGER = logging.getLogger()
34
35
  DEFAULT_SHELL = os.getenv("ZRB_SHELL", _get_current_shell())
35
36
  DEFAULT_EDITOR = os.getenv("ZRB_EDITOR", "nano")
36
37
  INIT_MODULES_STR = os.getenv("ZRB_INIT_MODULES", "")
zrb/llm_config.py CHANGED
@@ -6,27 +6,61 @@ from pydantic_ai.providers import Provider
6
6
  from pydantic_ai.providers.openai import OpenAIProvider
7
7
 
8
8
  DEFAULT_SYSTEM_PROMPT = """
9
- You have access to tools.
10
- Your goal is to provide insightful and accurate information based on user queries.
11
- Follow these instructions precisely:
12
- 1. ALWAYS use available tools to gather information BEFORE asking the user questions.
13
- 2. For tools that require arguments: provide arguments in valid JSON format.
14
- 3. For tools with no args: call the tool without args. Do NOT pass "" or {}.
15
- 4. NEVER pass arguments to tools that don't accept parameters.
16
- 5. NEVER ask users for information obtainable through tools.
17
- 6. Use tools in a logical sequence until you have sufficient information.
18
- 7. If a tool call fails, check if you're passing arguments in the correct format.
19
- Consider alternative strategies if the issue persists.
20
- 8. Only after exhausting relevant tools should you request clarification.
21
- 9. Understand the context of user queries to provide relevant and accurate responses.
22
- 10. Engage with users in a conversational manner once the necessary information is gathered.
23
- 11. Adapt to different query types or scenarios to improve flexibility and effectiveness.
9
+ You are a highly capable AI assistant with access to tools. Your primary goal is to
10
+ provide accurate, reliable, and helpful responses.
11
+
12
+ Key Instructions:
13
+ 1. **Prioritize Tool Use:** Always attempt to use available tools to find
14
+ information or perform actions before asking the user.
15
+ 2. **Correct Tool Invocation:** Use tools precisely as defined. Provide arguments
16
+ in valid JSON where required. Do not pass arguments to tools that don't accept
17
+ them. Handle tool errors gracefully and retry or adapt your strategy if necessary.
18
+ 3. **Accuracy is Paramount:** Ensure all information, code, or outputs provided are
19
+ correct and reliable. Verify facts and generate executable code when requested.
20
+ 4. **Clarity and Conciseness:** Respond clearly and directly to the user's query
21
+ after gathering the necessary information. Avoid unnecessary conversation.
22
+ 5. **Context Awareness:** Understand the user's request fully to provide the most
23
+ relevant and effective assistance.
24
24
  """.strip()
25
25
 
26
26
  DEFAULT_PERSONA = """
27
27
  You are an expert in various fields including technology, science, history, and more.
28
28
  """.strip()
29
29
 
30
+ DEFAULT_SUMMARIZATION_PROMPT = """
31
+ You are a summarization assistant. Your task is to synthesize the provided
32
+ conversation history and the existing context (which might contain a
33
+ previous 'history_summary') into a comprehensive, updated summary
34
+ Carefully review the '# Current Context' which includes any previous summary
35
+ ('history_summary').
36
+ Then, review the '# Conversation History to Summarize'.
37
+ Combine the information from both the existing context/summary and the new
38
+ history. Create a single, coherent summary that captures ALL essential
39
+ information, including:
40
+ - Key decisions made
41
+ - Actions taken (including tool usage and their results)
42
+ - Important facts or data points mentioned
43
+ - Outcomes of discussions or actions
44
+ - Any unresolved questions or pending items
45
+ The goal is to provide a complete yet concise background so that the main
46
+ assistant can seamlessly continue the conversation without losing critical
47
+ context from the summarized parts.
48
+ Output *only* the updated summary text."
49
+ """.strip()
50
+
51
+ DEFAULT_CONTEXT_ENRICHMENT_PROMPT = """
52
+ You are an information extraction assistant.
53
+ Analyze the conversation history and current context to extract key facts such as:
54
+ - user_name
55
+ - user_roles
56
+ - preferences
57
+ - goals
58
+ - etc
59
+ Return only a JSON object containing a single key "response",
60
+ whose value is another JSON object with these details.
61
+ If nothing is found, output {"response": {}}.
62
+ """.strip()
63
+
30
64
 
31
65
  class LLMConfig:
32
66
 
@@ -37,6 +71,8 @@ class LLMConfig:
37
71
  default_api_key: str | None = None,
38
72
  default_persona: str | None = None,
39
73
  default_system_prompt: str | None = None,
74
+ default_summarization_prompt: str | None = None,
75
+ default_context_enrichment_prompt: str | None = None,
40
76
  ):
41
77
  self._default_model_name = (
42
78
  default_model_name
@@ -63,6 +99,16 @@ class LLMConfig:
63
99
  if default_persona is not None
64
100
  else os.getenv("ZRB_LLM_PERSONA", None)
65
101
  )
102
+ self._default_summarization_prompt = (
103
+ default_summarization_prompt
104
+ if default_summarization_prompt is not None
105
+ else os.getenv("ZRB_LLM_SUMMARIZATION_PROMPT", None)
106
+ )
107
+ self._default_context_enrichment_prompt = (
108
+ default_context_enrichment_prompt
109
+ if default_context_enrichment_prompt is not None
110
+ else os.getenv("ZRB_LLM_CONTEXT_ENRICHMENT_PROMPT", None)
111
+ )
66
112
  self._default_provider = None
67
113
  self._default_model = None
68
114
 
@@ -93,6 +139,20 @@ class LLMConfig:
93
139
  return f"{persona}\n{system_prompt}"
94
140
  return system_prompt
95
141
 
142
+ def get_default_summarization_prompt(self) -> str:
143
+ return (
144
+ DEFAULT_SUMMARIZATION_PROMPT
145
+ if self._default_summarization_prompt is None
146
+ else self._default_summarization_prompt
147
+ )
148
+
149
+ def get_default_context_enrichment_prompt(self) -> str:
150
+ return (
151
+ DEFAULT_CONTEXT_ENRICHMENT_PROMPT
152
+ if self._default_context_enrichment_prompt is None
153
+ else self._default_context_enrichment_prompt
154
+ )
155
+
96
156
  def get_default_model(self) -> Model | str | None:
97
157
  if self._default_model is not None:
98
158
  return self._default_model
@@ -110,6 +170,12 @@ class LLMConfig:
110
170
  def set_default_system_prompt(self, system_prompt: str):
111
171
  self._default_system_prompt = system_prompt
112
172
 
173
+ def set_default_summarization_prompt(self, summarization_prompt: str):
174
+ self._default_summarization_prompt = summarization_prompt
175
+
176
+ def set_default_context_enrichment_prompt(self, context_enrichment_prompt: str):
177
+ self._default_context_enrichment_prompt = context_enrichment_prompt
178
+
113
179
  def set_default_model_name(self, model_name: str):
114
180
  self._default_model_name = model_name
115
181
 
File without changes
@@ -0,0 +1,53 @@
1
+ from typing import Any
2
+
3
+ from openai import APIError
4
+ from pydantic_ai import Agent
5
+ from pydantic_ai.messages import ModelMessagesTypeAdapter
6
+
7
+ from zrb.context.any_context import AnyContext
8
+ from zrb.task.llm.error import extract_api_error_details
9
+ from zrb.task.llm.history import ListOfDict
10
+ from zrb.task.llm.print_node import print_node
11
+
12
+
13
+ async def run_agent_iteration(
14
+ ctx: AnyContext,
15
+ agent: Agent,
16
+ user_prompt: str,
17
+ history_list: ListOfDict,
18
+ ) -> Any:
19
+ """
20
+ Runs a single iteration of the agent execution loop.
21
+
22
+ Args:
23
+ ctx: The task context.
24
+ agent: The Pydantic AI agent instance.
25
+ user_prompt: The user's input prompt.
26
+ history_list: The current conversation history.
27
+
28
+ Returns:
29
+ The agent run result object.
30
+
31
+ Raises:
32
+ Exception: If any error occurs during agent execution.
33
+ """
34
+ async with agent.run_mcp_servers():
35
+ async with agent.iter(
36
+ user_prompt=user_prompt,
37
+ message_history=ModelMessagesTypeAdapter.validate_python(history_list),
38
+ ) as agent_run:
39
+ async for node in agent_run:
40
+ # Each node represents a step in the agent's execution
41
+ # Reference: https://ai.pydantic.dev/agents/#streaming
42
+ try:
43
+ await print_node(ctx.print, agent_run, node)
44
+ except APIError as e:
45
+ # Extract detailed error information from the response
46
+ error_details = extract_api_error_details(e)
47
+ ctx.log_error(f"API Error: {error_details}")
48
+ raise
49
+ except Exception as e:
50
+ ctx.log_error(f"Error processing node: {str(e)}")
51
+ ctx.log_error(f"Error type: {type(e).__name__}")
52
+ raise
53
+ return agent_run
@@ -0,0 +1,86 @@
1
+ import json
2
+ import traceback
3
+ from textwrap import dedent
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel
7
+ from pydantic_ai import Agent
8
+ from pydantic_ai.models import Model
9
+ from pydantic_ai.settings import ModelSettings
10
+
11
+ from zrb.context.any_context import AnyContext
12
+ from zrb.task.llm.agent_runner import run_agent_iteration
13
+ from zrb.task.llm.history import ListOfDict
14
+
15
+
16
+ # Configuration model for context enrichment
17
+ class EnrichmentConfig(BaseModel):
18
+ model_config = {"arbitrary_types_allowed": True}
19
+ model: Model | str | None = None
20
+ settings: ModelSettings | None = None
21
+ prompt: str
22
+ retries: int = 1
23
+
24
+
25
+ class EnrichmentResult(BaseModel):
26
+ response: dict[str, Any] # or further decompose as needed
27
+
28
+
29
+ async def enrich_context(
30
+ ctx: AnyContext,
31
+ config: EnrichmentConfig,
32
+ conversation_context: dict[str, Any],
33
+ history_list: ListOfDict,
34
+ ) -> dict[str, Any]:
35
+ """Runs an LLM call to extract key info and merge it into the context."""
36
+ ctx.log_info("Attempting to enrich conversation context...")
37
+ # Prepare context and history for the enrichment prompt
38
+ try:
39
+ context_json = json.dumps(conversation_context)
40
+ history_json = json.dumps(history_list)
41
+ # The user prompt will now contain the dynamic data
42
+ user_prompt_data = dedent(
43
+ f"""
44
+ Analyze the following
45
+ # Current Context
46
+ {context_json}
47
+ # Conversation History
48
+ {history_json}
49
+ """
50
+ ).strip()
51
+ except Exception as e:
52
+ ctx.log_warning(f"Error formatting context/history for enrichment: {e}")
53
+ return conversation_context # Return original context if formatting fails
54
+
55
+ enrichment_agent = Agent(
56
+ model=config.model,
57
+ # System prompt is part of the user prompt for this specific call
58
+ system_prompt=config.prompt, # Use the main prompt as system prompt
59
+ tools=[],
60
+ mcp_servers=[],
61
+ model_settings=config.settings,
62
+ retries=config.retries,
63
+ result_type=EnrichmentResult,
64
+ )
65
+
66
+ try:
67
+ enrichment_run = await run_agent_iteration(
68
+ ctx=ctx,
69
+ agent=enrichment_agent,
70
+ user_prompt=user_prompt_data, # Pass the formatted data as user prompt
71
+ history_list=[], # Enrichment agent doesn't need prior history itself
72
+ )
73
+ if enrichment_run and enrichment_run.result.data:
74
+ response = enrichment_run.result.data.response
75
+ if response:
76
+ conversation_context.update(response)
77
+ ctx.log_info("History summarized and added/updated in context.")
78
+ ctx.log_info(
79
+ f"New conversation context: {json.dumps(conversation_context)}"
80
+ )
81
+ else:
82
+ ctx.log_warning("Context enrichment return no data")
83
+ except Exception as e:
84
+ ctx.log_warning(f"Error during context enrichment LLM call: {e}")
85
+ traceback.print_exc()
86
+ return conversation_context
@@ -0,0 +1,45 @@
1
+ import datetime
2
+ import os
3
+ import platform
4
+ import re
5
+ from typing import Any
6
+
7
+ from zrb.util.file import read_dir, read_file_with_line_numbers
8
+
9
+
10
+ def get_default_context(user_message: str) -> dict[str, Any]:
11
+ references = re.findall(r"@(\S+)", user_message)
12
+ current_references = []
13
+
14
+ for ref in references:
15
+ resource_path = os.path.abspath(os.path.expanduser(ref))
16
+ if os.path.isfile(resource_path):
17
+ content = read_file_with_line_numbers(resource_path)
18
+ current_references.append(
19
+ {
20
+ "reference": ref,
21
+ "name": resource_path,
22
+ "type": "file",
23
+ "note": "line numbers are included in the content",
24
+ "content": content,
25
+ }
26
+ )
27
+ elif os.path.isdir(resource_path):
28
+ content = read_dir(resource_path)
29
+ current_references.append(
30
+ {
31
+ "reference": ref,
32
+ "name": resource_path,
33
+ "type": "directory",
34
+ "content": content,
35
+ }
36
+ )
37
+
38
+ return {
39
+ "current_time": datetime.datetime.now().isoformat(),
40
+ "current_working_directory": os.getcwd(),
41
+ "current_os": platform.system(),
42
+ "os_version": platform.version(),
43
+ "python_version": platform.python_version(),
44
+ "current_references": current_references,
45
+ }
zrb/task/llm/error.py ADDED
@@ -0,0 +1,77 @@
1
+ import json
2
+ from typing import Optional
3
+
4
+ from openai import APIError
5
+ from pydantic import BaseModel
6
+
7
+
8
+ # Define a structured error model for tool execution failures
9
+ class ToolExecutionError(BaseModel):
10
+ tool_name: str
11
+ error_type: str
12
+ message: str
13
+ details: Optional[str] = None
14
+
15
+
16
+ def extract_api_error_details(error: APIError) -> str:
17
+ """Extract detailed error information from an APIError."""
18
+ details = f"{error.message}"
19
+ # Try to parse the error body as JSON
20
+ if error.body:
21
+ try:
22
+ if isinstance(error.body, str):
23
+ body_json = json.loads(error.body)
24
+ elif isinstance(error.body, bytes):
25
+ body_json = json.loads(error.body.decode("utf-8"))
26
+ else:
27
+ body_json = error.body
28
+ # Extract error details from the JSON structure
29
+ if isinstance(body_json, dict):
30
+ if "error" in body_json:
31
+ error_obj = body_json["error"]
32
+ if isinstance(error_obj, dict):
33
+ if "message" in error_obj:
34
+ details += f"\nProvider message: {error_obj['message']}"
35
+ if "code" in error_obj:
36
+ details += f"\nError code: {error_obj['code']}"
37
+ if "status" in error_obj:
38
+ details += f"\nStatus: {error_obj['status']}"
39
+ # Check for metadata that might contain provider-specific information
40
+ if "metadata" in body_json and isinstance(body_json["metadata"], dict):
41
+ metadata = body_json["metadata"]
42
+ if "provider_name" in metadata:
43
+ details += f"\nProvider: {metadata['provider_name']}"
44
+ if "raw" in metadata:
45
+ try:
46
+ raw_json = json.loads(metadata["raw"])
47
+ if "error" in raw_json and isinstance(
48
+ raw_json["error"], dict
49
+ ):
50
+ raw_error = raw_json["error"]
51
+ if "message" in raw_error:
52
+ details += (
53
+ f"\nRaw error message: {raw_error['message']}"
54
+ )
55
+ except (KeyError, TypeError, ValueError):
56
+ # If we can't parse the raw JSON, just include it as is
57
+ details += f"\nRaw error data: {metadata['raw']}"
58
+ except json.JSONDecodeError:
59
+ # If we can't parse the JSON, include the raw body
60
+ details += f"\nRaw error body: {error.body}"
61
+ except Exception as e:
62
+ # Catch any other exceptions during parsing
63
+ details += f"\nError parsing error body: {str(e)}"
64
+ # Include request information if available
65
+ if hasattr(error, "request") and error.request:
66
+ if hasattr(error.request, "method") and hasattr(error.request, "url"):
67
+ details += f"\nRequest: {error.request.method} {error.request.url}"
68
+ # Include a truncated version of the request content if available
69
+ if hasattr(error.request, "content") and error.request.content:
70
+ content = error.request.content
71
+ if isinstance(content, bytes):
72
+ try:
73
+ content = content.decode("utf-8")
74
+ except UnicodeDecodeError:
75
+ content = str(content)
76
+ details += f"\nRequest content: {content}"
77
+ return details
@@ -0,0 +1,92 @@
1
+ import json
2
+ import os
3
+ from collections.abc import Callable
4
+ from typing import Any, Optional
5
+
6
+ from pydantic import BaseModel
7
+
8
+ from zrb.context.any_context import AnyContext
9
+ from zrb.util.file import read_file
10
+ from zrb.util.run import run_async
11
+
12
+ ListOfDict = list[dict[str, Any]]
13
+
14
+
15
+ # Define the new ConversationHistoryData model
16
+ class ConversationHistoryData(BaseModel):
17
+ context: dict[str, Any] = {}
18
+ history: ListOfDict = []
19
+
20
+ @classmethod
21
+ async def read_from_sources(
22
+ cls,
23
+ ctx: AnyContext,
24
+ reader: Callable[[AnyContext], dict[str, Any] | list | None] | None,
25
+ file_path: str | None,
26
+ ) -> Optional["ConversationHistoryData"]:
27
+ """Reads conversation history from various sources with priority."""
28
+ # Priority 1: Reader function
29
+ if reader:
30
+ try:
31
+ raw_data = await run_async(reader(ctx))
32
+ if raw_data:
33
+ instance = cls.parse_and_validate(ctx, raw_data, "reader")
34
+ if instance:
35
+ return instance
36
+ except Exception as e:
37
+ ctx.log_warning(
38
+ f"Error executing conversation history reader: {e}. Ignoring."
39
+ )
40
+ # Priority 2: History file
41
+ if file_path and os.path.isfile(file_path):
42
+ try:
43
+ content = read_file(file_path)
44
+ raw_data = json.loads(content)
45
+ instance = cls.parse_and_validate(ctx, raw_data, f"file '{file_path}'")
46
+ if instance:
47
+ return instance
48
+ except json.JSONDecodeError:
49
+ ctx.log_warning(
50
+ f"Could not decode JSON from history file '{file_path}'. "
51
+ "Ignoring file content."
52
+ )
53
+ except Exception as e:
54
+ ctx.log_warning(
55
+ f"Error reading history file '{file_path}': {e}. "
56
+ "Ignoring file content."
57
+ )
58
+ # If neither reader nor file provided valid data
59
+ return None
60
+
61
+ @classmethod
62
+ def parse_and_validate(
63
+ cls, ctx: AnyContext, data: Any, source: str
64
+ ) -> Optional["ConversationHistoryData"]:
65
+ """Parses raw data into ConversationHistoryData, handling validation & old formats."""
66
+ try:
67
+ if isinstance(data, cls):
68
+ return data # Already a valid instance
69
+ if isinstance(data, dict) and "history" in data:
70
+ # Standard format {'context': ..., 'history': ...}
71
+ # Ensure context exists, even if empty
72
+ data.setdefault("context", {})
73
+ return cls.model_validate(data)
74
+ elif isinstance(data, list):
75
+ # Handle old format (just a list) - wrap it
76
+ ctx.log_warning(
77
+ f"History from {source} contains old list format. "
78
+ "Wrapping it into the new structure {'context': {}, 'history': [...]}. "
79
+ "Consider updating the source format."
80
+ )
81
+ return cls(history=data, context={})
82
+ else:
83
+ ctx.log_warning(
84
+ f"History data from {source} has unexpected format "
85
+ f"(type: {type(data)}). Ignoring."
86
+ )
87
+ return None
88
+ except Exception as e: # Catch validation errors too
89
+ ctx.log_warning(
90
+ f"Error validating/parsing history data from {source}: {e}. Ignoring."
91
+ )
92
+ return None