zrb 1.5.5__py3-none-any.whl → 1.5.6__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.
- zrb/__init__.py +2 -0
- zrb/__main__.py +28 -2
- zrb/builtin/llm/history.py +73 -0
- zrb/builtin/llm/input.py +27 -0
- zrb/builtin/llm/llm_chat.py +4 -61
- zrb/builtin/llm/tool/api.py +39 -17
- zrb/builtin/llm/tool/cli.py +19 -5
- zrb/builtin/llm/tool/file.py +270 -131
- zrb/builtin/llm/tool/rag.py +18 -1
- zrb/builtin/llm/tool/web.py +31 -14
- zrb/builtin/project/add/fastapp/fastapp_template/my_app_name/common/error.py +6 -8
- zrb/config.py +1 -0
- zrb/llm_config.py +81 -15
- zrb/task/llm/__init__.py +0 -0
- zrb/task/llm/agent_runner.py +53 -0
- zrb/task/llm/context_enricher.py +86 -0
- zrb/task/llm/default_context.py +44 -0
- zrb/task/llm/error.py +77 -0
- zrb/task/llm/history.py +92 -0
- zrb/task/llm/history_summarizer.py +71 -0
- zrb/task/llm/print_node.py +98 -0
- zrb/task/llm/tool_wrapper.py +88 -0
- zrb/task/llm_task.py +279 -246
- zrb/util/file.py +8 -2
- zrb/util/load.py +2 -0
- {zrb-1.5.5.dist-info → zrb-1.5.6.dist-info}/METADATA +1 -1
- {zrb-1.5.5.dist-info → zrb-1.5.6.dist-info}/RECORD +29 -18
- {zrb-1.5.5.dist-info → zrb-1.5.6.dist-info}/WHEEL +0 -0
- {zrb-1.5.5.dist-info → zrb-1.5.6.dist-info}/entry_points.txt +0 -0
@@ -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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
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
|
|
zrb/task/llm/__init__.py
ADDED
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,44 @@
|
|
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
|
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(resource_path)
|
18
|
+
current_references.append(
|
19
|
+
{
|
20
|
+
"reference": ref,
|
21
|
+
"name": resource_path,
|
22
|
+
"type": "file",
|
23
|
+
"content": content,
|
24
|
+
}
|
25
|
+
)
|
26
|
+
elif os.path.isdir(resource_path):
|
27
|
+
content = read_dir(resource_path)
|
28
|
+
current_references.append(
|
29
|
+
{
|
30
|
+
"reference": ref,
|
31
|
+
"name": resource_path,
|
32
|
+
"type": "directory",
|
33
|
+
"content": content,
|
34
|
+
}
|
35
|
+
)
|
36
|
+
|
37
|
+
return {
|
38
|
+
"current_time": datetime.datetime.now().isoformat(),
|
39
|
+
"current_working_directory": os.getcwd(),
|
40
|
+
"current_os": platform.system(),
|
41
|
+
"os_version": platform.version(),
|
42
|
+
"python_version": platform.python_version(),
|
43
|
+
"current_references": current_references,
|
44
|
+
}
|
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
|
zrb/task/llm/history.py
ADDED
@@ -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
|
@@ -0,0 +1,71 @@
|
|
1
|
+
import json
|
2
|
+
from typing import Any
|
3
|
+
|
4
|
+
from pydantic import BaseModel
|
5
|
+
from pydantic_ai import Agent
|
6
|
+
from pydantic_ai.models import Model
|
7
|
+
from pydantic_ai.settings import ModelSettings
|
8
|
+
|
9
|
+
from zrb.context.any_context import AnyContext
|
10
|
+
from zrb.task.llm.agent_runner import run_agent_iteration
|
11
|
+
from zrb.task.llm.history import ListOfDict
|
12
|
+
|
13
|
+
|
14
|
+
# Configuration model for history summarization
|
15
|
+
class SummarizationConfig(BaseModel):
|
16
|
+
model_config = {"arbitrary_types_allowed": True}
|
17
|
+
model: Model | str | None = None
|
18
|
+
settings: ModelSettings | None = None
|
19
|
+
prompt: str
|
20
|
+
retries: int = 1
|
21
|
+
|
22
|
+
|
23
|
+
async def summarize_history(
|
24
|
+
ctx: AnyContext,
|
25
|
+
config: SummarizationConfig,
|
26
|
+
conversation_context: dict[str, Any],
|
27
|
+
history_list: ListOfDict,
|
28
|
+
) -> dict[str, Any]:
|
29
|
+
"""Runs an LLM call to summarize history and update the context."""
|
30
|
+
ctx.log_info("Attempting to summarize conversation history...")
|
31
|
+
|
32
|
+
summarization_agent = Agent(
|
33
|
+
model=config.model,
|
34
|
+
system_prompt=config.prompt,
|
35
|
+
tools=[], # No tools needed for summarization
|
36
|
+
mcp_servers=[],
|
37
|
+
model_settings=config.settings,
|
38
|
+
retries=config.retries,
|
39
|
+
)
|
40
|
+
|
41
|
+
# Prepare context and history for summarization prompt
|
42
|
+
try:
|
43
|
+
context_json = json.dumps(conversation_context)
|
44
|
+
history_to_summarize_json = json.dumps(history_list)
|
45
|
+
summarization_user_prompt = (
|
46
|
+
f"# Current Context\n{context_json}\n\n"
|
47
|
+
f"# Conversation History to Summarize\n{history_to_summarize_json}"
|
48
|
+
)
|
49
|
+
except Exception as e:
|
50
|
+
ctx.log_warning(f"Error formatting context/history for summarization: {e}")
|
51
|
+
return conversation_context # Return original context if formatting fails
|
52
|
+
|
53
|
+
try:
|
54
|
+
summary_run = await run_agent_iteration(
|
55
|
+
ctx=ctx,
|
56
|
+
agent=summarization_agent,
|
57
|
+
user_prompt=summarization_user_prompt,
|
58
|
+
history_list=[], # Summarization agent doesn't need prior history
|
59
|
+
)
|
60
|
+
if summary_run and summary_run.result.data:
|
61
|
+
summary_text = str(summary_run.result.data)
|
62
|
+
# Update context with the new summary
|
63
|
+
conversation_context["history_summary"] = summary_text
|
64
|
+
ctx.log_info("History summarized and added/updated in context.")
|
65
|
+
ctx.log_info(f"Conversaion summary: {summary_text}")
|
66
|
+
else:
|
67
|
+
ctx.log_warning("History summarization failed or returned no data.")
|
68
|
+
except Exception as e:
|
69
|
+
ctx.log_warning(f"Error during history summarization: {e}")
|
70
|
+
|
71
|
+
return conversation_context
|