agentic-ai-kit 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.
- agentic_ai/__init__.py +24 -0
- agentic_ai/agents/__init__.py +17 -0
- agentic_ai/agents/analyst_agent.py +239 -0
- agentic_ai/agents/auto_model_agent.py +156 -0
- agentic_ai/agents/base.py +280 -0
- agentic_ai/agents/json_agent.py +73 -0
- agentic_ai/agents/mle_agent.py +590 -0
- agentic_ai/agents/reasoning_agent.py +97 -0
- agentic_ai/agents/tool_agent.py +171 -0
- agentic_ai/examples/01_talking_agents.py +9 -0
- agentic_ai/examples/02_personality_and_memory.py +12 -0
- agentic_ai/examples/03_tool_agent_weather.py +10 -0
- agentic_ai/examples/04_json_agent.py +11 -0
- agentic_ai/examples/05_reasoning_agent.py +9 -0
- agentic_ai/examples/06_supervisor.py +16 -0
- agentic_ai/examples/07_parallel_agents.py +15 -0
- agentic_ai/examples/08_debate.py +16 -0
- agentic_ai/examples/09_long_term_memory.py +23 -0
- agentic_ai/examples/10_rag_agent.py +29 -0
- agentic_ai/examples/11_mcp_demo.py +41 -0
- agentic_ai/examples/__init__.py +1 -0
- agentic_ai/mcp/__init__.py +4 -0
- agentic_ai/mcp/client.py +55 -0
- agentic_ai/mcp/server.py +133 -0
- agentic_ai/memory/__init__.py +5 -0
- agentic_ai/memory/long_term.py +104 -0
- agentic_ai/memory/shared.py +52 -0
- agentic_ai/memory/short_term.py +43 -0
- agentic_ai/patterns/__init__.py +5 -0
- agentic_ai/patterns/debate.py +49 -0
- agentic_ai/patterns/orchestrator.py +91 -0
- agentic_ai/patterns/parallel.py +39 -0
- agentic_ai/py.typed +0 -0
- agentic_ai/rag/__init__.py +6 -0
- agentic_ai/rag/chunker.py +23 -0
- agentic_ai/rag/embedder.py +30 -0
- agentic_ai/rag/rag_agent.py +42 -0
- agentic_ai/rag/vector_store.py +51 -0
- agentic_ai/tools/__init__.py +8 -0
- agentic_ai/tools/builtins.py +51 -0
- agentic_ai/tools/registry.py +120 -0
- agentic_ai_kit-0.1.1.dist-info/METADATA +201 -0
- agentic_ai_kit-0.1.1.dist-info/RECORD +45 -0
- agentic_ai_kit-0.1.1.dist-info/WHEEL +5 -0
- agentic_ai_kit-0.1.1.dist-info/top_level.txt +1 -0
agentic_ai/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agentic_ai — A progressive framework for building agentic AI systems with Google Gemini.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from agentic_ai.agents.base import BaseAgent
|
|
6
|
+
from agentic_ai.agents.tool_agent import ToolAgent
|
|
7
|
+
from agentic_ai.agents.json_agent import JsonAgent
|
|
8
|
+
from agentic_ai.agents.reasoning_agent import ReasoningAgent
|
|
9
|
+
from agentic_ai.memory.short_term import ShortTermMemory
|
|
10
|
+
from agentic_ai.memory.long_term import LongTermMemory
|
|
11
|
+
from agentic_ai.memory.shared import SharedMemory
|
|
12
|
+
from agentic_ai.agents.auto_model_agent import AutoModelAgent
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
__all__ = [
|
|
16
|
+
"BaseAgent",
|
|
17
|
+
"ToolAgent",
|
|
18
|
+
"JsonAgent",
|
|
19
|
+
"ReasoningAgent",
|
|
20
|
+
"ShortTermMemory",
|
|
21
|
+
"LongTermMemory",
|
|
22
|
+
"SharedMemory",
|
|
23
|
+
"AutoModelAgent",
|
|
24
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from agentic_ai.agents.base import BaseAgent
|
|
2
|
+
from agentic_ai.agents.tool_agent import ToolAgent
|
|
3
|
+
from agentic_ai.agents.json_agent import JsonAgent
|
|
4
|
+
from agentic_ai.agents.reasoning_agent import ReasoningAgent
|
|
5
|
+
from agentic_ai.agents.analyst_agent import AnalystAgent
|
|
6
|
+
from agentic_ai.agents.mle_agent import MLEAgent
|
|
7
|
+
from agentic_ai.agents.auto_model_agent import AutoModelAgent
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"BaseAgent",
|
|
11
|
+
"ToolAgent",
|
|
12
|
+
"JsonAgent",
|
|
13
|
+
"ReasoningAgent",
|
|
14
|
+
"AnalystAgent",
|
|
15
|
+
"MLEAgent",
|
|
16
|
+
"AutoModelAgent",
|
|
17
|
+
]
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AnalystAgent: dataframe-aware analytics specialist agent.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
from agentic_ai.agents.base import BaseAgent
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_ANALYST_PROMPT = """
|
|
15
|
+
You are an elite Decision Scientist.
|
|
16
|
+
|
|
17
|
+
You help users analyze pandas DataFrames and business datasets.
|
|
18
|
+
|
|
19
|
+
You are expected to reason using:
|
|
20
|
+
- dataframe schema
|
|
21
|
+
- row counts
|
|
22
|
+
- missing values
|
|
23
|
+
- numeric summaries
|
|
24
|
+
- categorical distributions
|
|
25
|
+
- group-level metrics
|
|
26
|
+
- data quality risks
|
|
27
|
+
- business interpretation
|
|
28
|
+
|
|
29
|
+
Always separate:
|
|
30
|
+
1. what the data says
|
|
31
|
+
2. what it might mean
|
|
32
|
+
3. what should be checked next
|
|
33
|
+
|
|
34
|
+
Preferred response structure:
|
|
35
|
+
|
|
36
|
+
BUSINESS QUESTION
|
|
37
|
+
DATA READ
|
|
38
|
+
KEY OBSERVATIONS
|
|
39
|
+
DATA QUALITY RISKS
|
|
40
|
+
RECOMMENDED ANALYSIS
|
|
41
|
+
DECISION SUMMARY
|
|
42
|
+
|
|
43
|
+
Do not invent numbers.
|
|
44
|
+
If dataframe context is provided, use it explicitly.
|
|
45
|
+
""".strip()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AnalystAgent(BaseAgent):
|
|
49
|
+
"""Analytics-focused specialist agent with dataframe tools."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
name: str = "Analyst",
|
|
54
|
+
domain_context: str | None = None,
|
|
55
|
+
model: str = "gemini-2.5-flash-lite",
|
|
56
|
+
api_key: str | None = None,
|
|
57
|
+
memory_window: int = 3,
|
|
58
|
+
max_turns: int | None = None,
|
|
59
|
+
thinking_budget: int = 0,
|
|
60
|
+
):
|
|
61
|
+
prompt = _ANALYST_PROMPT
|
|
62
|
+
|
|
63
|
+
if domain_context:
|
|
64
|
+
prompt += f"\n\nDOMAIN CONTEXT:\n{domain_context.strip()}"
|
|
65
|
+
|
|
66
|
+
super().__init__(
|
|
67
|
+
name=name,
|
|
68
|
+
sys_prompt=prompt,
|
|
69
|
+
model=model,
|
|
70
|
+
api_key=api_key,
|
|
71
|
+
memory_window=memory_window,
|
|
72
|
+
max_turns=max_turns,
|
|
73
|
+
thinking_budget=thinking_budget,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
# DataFrame tools
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def profile_dataframe(self, df: pd.DataFrame) -> dict[str, Any]:
|
|
81
|
+
"""Return a consistent dataframe profile."""
|
|
82
|
+
|
|
83
|
+
self._validate_dataframe(df)
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
"rows": int(df.shape[0]),
|
|
87
|
+
"columns": int(df.shape[1]),
|
|
88
|
+
"column_names": list(df.columns),
|
|
89
|
+
"dtypes": {col: str(dtype) for col, dtype in df.dtypes.items()},
|
|
90
|
+
"missing_values": df.isna().sum().to_dict(),
|
|
91
|
+
"missing_percentage": (
|
|
92
|
+
df.isna().mean().mul(100).round(2).to_dict()
|
|
93
|
+
),
|
|
94
|
+
"duplicate_rows": int(df.duplicated().sum()),
|
|
95
|
+
"numeric_columns": list(df.select_dtypes(include="number").columns),
|
|
96
|
+
"categorical_columns": list(
|
|
97
|
+
df.select_dtypes(include=["object", "category", "bool"]).columns
|
|
98
|
+
),
|
|
99
|
+
"datetime_columns": list(
|
|
100
|
+
df.select_dtypes(include=["datetime", "datetimetz"]).columns
|
|
101
|
+
),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
def numeric_summary(self, df: pd.DataFrame) -> dict[str, Any]:
|
|
105
|
+
"""Return numeric column summary."""
|
|
106
|
+
|
|
107
|
+
self._validate_dataframe(df)
|
|
108
|
+
|
|
109
|
+
numeric_df = df.select_dtypes(include="number")
|
|
110
|
+
|
|
111
|
+
if numeric_df.empty:
|
|
112
|
+
return {"message": "No numeric columns found."}
|
|
113
|
+
|
|
114
|
+
return numeric_df.describe().round(4).to_dict()
|
|
115
|
+
|
|
116
|
+
def categorical_summary(
|
|
117
|
+
self,
|
|
118
|
+
df: pd.DataFrame,
|
|
119
|
+
top_n: int = 10,
|
|
120
|
+
) -> dict[str, Any]:
|
|
121
|
+
"""Return top category counts for categorical columns."""
|
|
122
|
+
|
|
123
|
+
self._validate_dataframe(df)
|
|
124
|
+
|
|
125
|
+
categorical_df = df.select_dtypes(include=["object", "category", "bool"])
|
|
126
|
+
|
|
127
|
+
if categorical_df.empty:
|
|
128
|
+
return {"message": "No categorical columns found."}
|
|
129
|
+
|
|
130
|
+
summary = {}
|
|
131
|
+
|
|
132
|
+
for col in categorical_df.columns:
|
|
133
|
+
summary[col] = (
|
|
134
|
+
categorical_df[col]
|
|
135
|
+
.value_counts(dropna=False)
|
|
136
|
+
.head(top_n)
|
|
137
|
+
.to_dict()
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return summary
|
|
141
|
+
|
|
142
|
+
def groupby_summary(
|
|
143
|
+
self,
|
|
144
|
+
df: pd.DataFrame,
|
|
145
|
+
group_col: str,
|
|
146
|
+
metric_col: str,
|
|
147
|
+
agg: str = "mean",
|
|
148
|
+
) -> dict[str, Any]:
|
|
149
|
+
"""Return grouped metric summary."""
|
|
150
|
+
|
|
151
|
+
self._validate_dataframe(df)
|
|
152
|
+
self._validate_columns(df, [group_col, metric_col])
|
|
153
|
+
|
|
154
|
+
allowed_aggs = {"mean", "sum", "count", "median", "min", "max"}
|
|
155
|
+
|
|
156
|
+
if agg not in allowed_aggs:
|
|
157
|
+
raise ValueError(f"agg must be one of {sorted(allowed_aggs)}")
|
|
158
|
+
|
|
159
|
+
result = (
|
|
160
|
+
df.groupby(group_col, dropna=False)[metric_col]
|
|
161
|
+
.agg(agg)
|
|
162
|
+
.reset_index()
|
|
163
|
+
.sort_values(metric_col, ascending=False)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
return result.to_dict(orient="records")
|
|
167
|
+
|
|
168
|
+
def correlation_summary(self, df: pd.DataFrame) -> dict[str, Any]:
|
|
169
|
+
"""Return numeric correlation matrix."""
|
|
170
|
+
|
|
171
|
+
self._validate_dataframe(df)
|
|
172
|
+
|
|
173
|
+
numeric_df = df.select_dtypes(include="number")
|
|
174
|
+
|
|
175
|
+
if numeric_df.shape[1] < 2:
|
|
176
|
+
return {"message": "At least two numeric columns are needed."}
|
|
177
|
+
|
|
178
|
+
return numeric_df.corr().round(4).to_dict()
|
|
179
|
+
|
|
180
|
+
def dataframe_context(
|
|
181
|
+
self,
|
|
182
|
+
df: pd.DataFrame,
|
|
183
|
+
include_numeric: bool = True,
|
|
184
|
+
include_categorical: bool = True,
|
|
185
|
+
) -> str:
|
|
186
|
+
"""Create dataframe context for the LLM."""
|
|
187
|
+
|
|
188
|
+
context = {
|
|
189
|
+
"profile": self.profile_dataframe(df),
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if include_numeric:
|
|
193
|
+
context["numeric_summary"] = self.numeric_summary(df)
|
|
194
|
+
|
|
195
|
+
if include_categorical:
|
|
196
|
+
context["categorical_summary"] = self.categorical_summary(df)
|
|
197
|
+
|
|
198
|
+
return str(context)
|
|
199
|
+
|
|
200
|
+
def analyze_dataframe(
|
|
201
|
+
self,
|
|
202
|
+
df: pd.DataFrame,
|
|
203
|
+
question: str,
|
|
204
|
+
stream: bool = True,
|
|
205
|
+
) -> str:
|
|
206
|
+
"""Analyze a dataframe using generated dataframe context."""
|
|
207
|
+
|
|
208
|
+
context = self.dataframe_context(df)
|
|
209
|
+
|
|
210
|
+
prompt = f"""
|
|
211
|
+
Analyze the dataframe for the following question.
|
|
212
|
+
|
|
213
|
+
QUESTION:
|
|
214
|
+
{question}
|
|
215
|
+
|
|
216
|
+
DATAFRAME CONTEXT:
|
|
217
|
+
{context}
|
|
218
|
+
""".strip()
|
|
219
|
+
|
|
220
|
+
return self.think(prompt, stream=stream)
|
|
221
|
+
|
|
222
|
+
# ------------------------------------------------------------------
|
|
223
|
+
# Validation helpers
|
|
224
|
+
# ------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
@staticmethod
|
|
227
|
+
def _validate_dataframe(df: pd.DataFrame) -> None:
|
|
228
|
+
if not isinstance(df, pd.DataFrame):
|
|
229
|
+
raise TypeError("Expected a pandas DataFrame.")
|
|
230
|
+
|
|
231
|
+
if df.empty:
|
|
232
|
+
raise ValueError("DataFrame is empty.")
|
|
233
|
+
|
|
234
|
+
@staticmethod
|
|
235
|
+
def _validate_columns(df: pd.DataFrame, columns: list[str]) -> None:
|
|
236
|
+
missing = [col for col in columns if col not in df.columns]
|
|
237
|
+
|
|
238
|
+
if missing:
|
|
239
|
+
raise ValueError(f"Missing columns: {missing}")
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AutoModelAgent: end-to-end dataframe-to-model agent.
|
|
3
|
+
|
|
4
|
+
The user gives:
|
|
5
|
+
- dataframe
|
|
6
|
+
- target column
|
|
7
|
+
- business objective
|
|
8
|
+
|
|
9
|
+
The agent:
|
|
10
|
+
- profiles the data
|
|
11
|
+
- designs modeling policy
|
|
12
|
+
- trains models using MLEAgent
|
|
13
|
+
- returns results and interpretation
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import pandas as pd
|
|
21
|
+
|
|
22
|
+
from agentic_ai.agents.mle_agent import MLEAgent
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AutoModelAgent(MLEAgent):
|
|
26
|
+
"""End-to-end modeling agent built on top of MLEAgent."""
|
|
27
|
+
|
|
28
|
+
def run(
|
|
29
|
+
self,
|
|
30
|
+
df: pd.DataFrame,
|
|
31
|
+
target_col: str,
|
|
32
|
+
objective: str,
|
|
33
|
+
drop_columns: list[str] | None = None,
|
|
34
|
+
test_size: float = 0.2,
|
|
35
|
+
random_state: int = 42,
|
|
36
|
+
stream: bool = True,
|
|
37
|
+
interpret: bool = True,
|
|
38
|
+
) -> dict[str, Any]:
|
|
39
|
+
"""Look at data, design policy, train model, and explain results."""
|
|
40
|
+
|
|
41
|
+
self._validate_dataframe(df)
|
|
42
|
+
self._validate_columns(df, [target_col])
|
|
43
|
+
|
|
44
|
+
profile = self.profile_dataframe(df)
|
|
45
|
+
target = self.target_summary(df, target_col)
|
|
46
|
+
features = self.feature_summary(df, target_col)
|
|
47
|
+
leakage = self.leakage_scan(df, target_col)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if not interpret:
|
|
51
|
+
model_result = self.train_model(
|
|
52
|
+
df=df,
|
|
53
|
+
target_col=target_col,
|
|
54
|
+
test_size=test_size,
|
|
55
|
+
random_state=random_state,
|
|
56
|
+
drop_columns=drop_columns,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
"objective": objective,
|
|
61
|
+
"target_column": target_col,
|
|
62
|
+
"data_profile": profile,
|
|
63
|
+
"target_summary": target,
|
|
64
|
+
"feature_summary": features,
|
|
65
|
+
"leakage_scan": leakage,
|
|
66
|
+
"modeling_policy": None,
|
|
67
|
+
"interpretation": None,
|
|
68
|
+
"model_result": model_result,
|
|
69
|
+
"best_model": model_result["best_model"],
|
|
70
|
+
"best_score": model_result["best_score"],
|
|
71
|
+
"best_pipeline": model_result["best_pipeline"],
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
policy_prompt = f"""
|
|
75
|
+
You are about to create a machine learning model.
|
|
76
|
+
|
|
77
|
+
BUSINESS OBJECTIVE:
|
|
78
|
+
{objective}
|
|
79
|
+
|
|
80
|
+
TARGET COLUMN:
|
|
81
|
+
{target_col}
|
|
82
|
+
|
|
83
|
+
DATA PROFILE:
|
|
84
|
+
{profile}
|
|
85
|
+
|
|
86
|
+
TARGET SUMMARY:
|
|
87
|
+
{target}
|
|
88
|
+
|
|
89
|
+
FEATURE SUMMARY:
|
|
90
|
+
{features}
|
|
91
|
+
|
|
92
|
+
LEAKAGE SCAN:
|
|
93
|
+
{leakage}
|
|
94
|
+
|
|
95
|
+
Design a concise modeling policy before training.
|
|
96
|
+
|
|
97
|
+
Include:
|
|
98
|
+
1. problem type
|
|
99
|
+
2. usable features
|
|
100
|
+
3. columns to drop, if any
|
|
101
|
+
4. leakage risks
|
|
102
|
+
5. train/test approach
|
|
103
|
+
6. model candidates
|
|
104
|
+
7. evaluation metric
|
|
105
|
+
8. expected limitations
|
|
106
|
+
|
|
107
|
+
Return a practical modeling policy.
|
|
108
|
+
""".strip()
|
|
109
|
+
|
|
110
|
+
modeling_policy = self.think(policy_prompt, stream=stream)
|
|
111
|
+
|
|
112
|
+
model_result = self.train_model(
|
|
113
|
+
df=df,
|
|
114
|
+
target_col=target_col,
|
|
115
|
+
test_size=test_size,
|
|
116
|
+
random_state=random_state,
|
|
117
|
+
drop_columns=drop_columns,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
result_prompt = f"""
|
|
121
|
+
A model has now been trained.
|
|
122
|
+
|
|
123
|
+
BUSINESS OBJECTIVE:
|
|
124
|
+
{objective}
|
|
125
|
+
|
|
126
|
+
MODELING POLICY:
|
|
127
|
+
{modeling_policy}
|
|
128
|
+
|
|
129
|
+
MODEL RESULT:
|
|
130
|
+
{self._safe_model_result_for_llm(model_result)}
|
|
131
|
+
|
|
132
|
+
Explain:
|
|
133
|
+
1. what model was created
|
|
134
|
+
2. which model performed best
|
|
135
|
+
3. what the metrics mean
|
|
136
|
+
4. whether the result is usable
|
|
137
|
+
5. what should be improved next
|
|
138
|
+
6. how this could be deployed
|
|
139
|
+
""".strip()
|
|
140
|
+
|
|
141
|
+
interpretation = self.think(result_prompt, stream=stream)
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
"objective": objective,
|
|
145
|
+
"target_column": target_col,
|
|
146
|
+
"data_profile": profile,
|
|
147
|
+
"target_summary": target,
|
|
148
|
+
"feature_summary": features,
|
|
149
|
+
"leakage_scan": leakage,
|
|
150
|
+
"modeling_policy": modeling_policy,
|
|
151
|
+
"model_result": model_result,
|
|
152
|
+
"interpretation": interpretation,
|
|
153
|
+
"best_model": model_result["best_model"],
|
|
154
|
+
"best_score": model_result["best_score"],
|
|
155
|
+
"best_pipeline": model_result["best_pipeline"],
|
|
156
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BaseAgent: the foundation for all agents in agentic_ai.
|
|
3
|
+
|
|
4
|
+
Every agent has:
|
|
5
|
+
- A name and system prompt that define its persona
|
|
6
|
+
- Short-term memory for recent context
|
|
7
|
+
- Optional max-turn retention at the interface level
|
|
8
|
+
- LLM-based fact extraction for lightweight user memory
|
|
9
|
+
- A `think(input_text)` method that streams a Gemini response
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
from google import genai
|
|
18
|
+
from google.genai import types
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BaseAgent:
|
|
22
|
+
"""Core agent backed by a Gemini model."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
name: str,
|
|
27
|
+
sys_prompt: str,
|
|
28
|
+
model: str = "gemini-2.5-flash-lite",
|
|
29
|
+
api_key: str | None = None,
|
|
30
|
+
memory_window: int = 3,
|
|
31
|
+
max_turns: int | None = None,
|
|
32
|
+
max_facts: int = 50,
|
|
33
|
+
extract_memory: bool = True,
|
|
34
|
+
thinking_budget: int = 0,
|
|
35
|
+
):
|
|
36
|
+
self.name = name
|
|
37
|
+
self.sys_prompt = sys_prompt
|
|
38
|
+
self.model = model
|
|
39
|
+
self.memory_window = memory_window
|
|
40
|
+
self.max_turns = max_turns
|
|
41
|
+
self.max_facts = max_facts
|
|
42
|
+
self.extract_memory = extract_memory
|
|
43
|
+
self.thinking_budget = thinking_budget
|
|
44
|
+
|
|
45
|
+
self.memory: list[tuple[str, str]] = []
|
|
46
|
+
self.facts_store: list[dict[str, object]] = []
|
|
47
|
+
|
|
48
|
+
key = api_key or os.environ.get("GEMINI_API_KEY")
|
|
49
|
+
if not key:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
"No Gemini API key found. Pass api_key= or set GEMINI_API_KEY."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
self.client = genai.Client(api_key=key)
|
|
55
|
+
|
|
56
|
+
# ------------------------------------------------------------------
|
|
57
|
+
# Memory helpers
|
|
58
|
+
# ------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def extract_facts(self, text: str) -> None:
|
|
61
|
+
"""Extract durable user facts from input using Gemini."""
|
|
62
|
+
|
|
63
|
+
if not self.extract_memory:
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
extraction_prompt = f"""
|
|
67
|
+
You are a memory extraction system.
|
|
68
|
+
|
|
69
|
+
Extract durable facts from the user's message.
|
|
70
|
+
|
|
71
|
+
A durable fact is something likely to remain useful in future conversations.
|
|
72
|
+
|
|
73
|
+
Examples of durable facts:
|
|
74
|
+
- User works as a data scientist.
|
|
75
|
+
- User prefers Python.
|
|
76
|
+
- User is building an agentic AI framework.
|
|
77
|
+
- User is learning PyTorch.
|
|
78
|
+
- User works on retail analytics.
|
|
79
|
+
|
|
80
|
+
Do not extract:
|
|
81
|
+
- Temporary requests
|
|
82
|
+
- Greetings
|
|
83
|
+
- One-off questions
|
|
84
|
+
- Generic statements
|
|
85
|
+
- Facts about the assistant
|
|
86
|
+
- Very sensitive personal information
|
|
87
|
+
|
|
88
|
+
Return ONLY valid JSON in this exact format:
|
|
89
|
+
|
|
90
|
+
[
|
|
91
|
+
{{
|
|
92
|
+
"fact": "User is building an agentic AI framework.",
|
|
93
|
+
"confidence": 0.95
|
|
94
|
+
}}
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
If there are no durable facts, return:
|
|
98
|
+
|
|
99
|
+
[]
|
|
100
|
+
|
|
101
|
+
User message:
|
|
102
|
+
{text}
|
|
103
|
+
""".strip()
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
response = self.client.models.generate_content(
|
|
107
|
+
model=self.model,
|
|
108
|
+
contents=extraction_prompt,
|
|
109
|
+
config=types.GenerateContentConfig(
|
|
110
|
+
temperature=0,
|
|
111
|
+
thinking_config=types.ThinkingConfig(thinking_budget=0),
|
|
112
|
+
),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
raw_output = (response.text or "").strip()
|
|
116
|
+
extracted = json.loads(raw_output)
|
|
117
|
+
|
|
118
|
+
if not isinstance(extracted, list):
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
for item in extracted:
|
|
122
|
+
if not isinstance(item, dict):
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
fact = str(item.get("fact", "")).strip()
|
|
126
|
+
confidence = item.get("confidence", 0.0)
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
confidence = float(confidence)
|
|
130
|
+
except (TypeError, ValueError):
|
|
131
|
+
confidence = 0.0
|
|
132
|
+
|
|
133
|
+
if not fact:
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
if confidence < 0.6:
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
if self._fact_exists(fact):
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
self.facts_store.append(
|
|
143
|
+
{
|
|
144
|
+
"fact": fact,
|
|
145
|
+
"confidence": confidence,
|
|
146
|
+
"source": text,
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
self.trim_facts()
|
|
151
|
+
|
|
152
|
+
except Exception:
|
|
153
|
+
# Memory extraction should never break the main agent response.
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
def _fact_exists(self, new_fact: str) -> bool:
|
|
157
|
+
"""Check whether a fact already exists in memory."""
|
|
158
|
+
|
|
159
|
+
normalized_new = new_fact.lower().strip().rstrip(".")
|
|
160
|
+
|
|
161
|
+
for item in self.facts_store:
|
|
162
|
+
existing = str(item.get("fact", "")).lower().strip().rstrip(".")
|
|
163
|
+
if existing == normalized_new:
|
|
164
|
+
return True
|
|
165
|
+
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
def build_context(self, input_text: str) -> str:
|
|
169
|
+
"""Assemble facts, recent history, and the new message."""
|
|
170
|
+
|
|
171
|
+
recent_turns = self.memory[-self.memory_window :]
|
|
172
|
+
|
|
173
|
+
history = "\n".join(
|
|
174
|
+
f"user: {user_input}\nassistant: {agent_output}"
|
|
175
|
+
for user_input, agent_output in recent_turns
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
if self.facts_store:
|
|
179
|
+
facts_text = "\n".join(
|
|
180
|
+
f"- {item['fact']}" for item in self.facts_store
|
|
181
|
+
)
|
|
182
|
+
else:
|
|
183
|
+
facts_text = "(none)"
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
f"FACTS:\n{facts_text}\n\n"
|
|
187
|
+
f"HISTORY:\n{history}\n\n"
|
|
188
|
+
f"NEW MESSAGE:\n{input_text}"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def trim_memory(self) -> None:
|
|
192
|
+
"""Limit retained conversation turns at the interface level."""
|
|
193
|
+
|
|
194
|
+
if self.max_turns is not None:
|
|
195
|
+
if self.max_turns < 0:
|
|
196
|
+
raise ValueError("max_turns must be None or a non-negative integer.")
|
|
197
|
+
|
|
198
|
+
self.memory = self.memory[-self.max_turns :]
|
|
199
|
+
|
|
200
|
+
def trim_facts(self) -> None:
|
|
201
|
+
"""Limit number of stored facts."""
|
|
202
|
+
|
|
203
|
+
if self.max_facts < 0:
|
|
204
|
+
raise ValueError("max_facts must be a non-negative integer.")
|
|
205
|
+
|
|
206
|
+
self.facts_store = self.facts_store[-self.max_facts :]
|
|
207
|
+
|
|
208
|
+
def clear_memory(self) -> None:
|
|
209
|
+
"""Wipe short-term memory and facts store."""
|
|
210
|
+
|
|
211
|
+
self.memory.clear()
|
|
212
|
+
self.facts_store.clear()
|
|
213
|
+
|
|
214
|
+
# ------------------------------------------------------------------
|
|
215
|
+
# Core generation
|
|
216
|
+
# ------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
def think(
|
|
219
|
+
self,
|
|
220
|
+
input_text: str,
|
|
221
|
+
use_memory: bool = True,
|
|
222
|
+
stream: bool = True,
|
|
223
|
+
) -> str:
|
|
224
|
+
"""Send input to Gemini and return the full response string."""
|
|
225
|
+
|
|
226
|
+
self.extract_facts(input_text)
|
|
227
|
+
prompt = self.build_context(input_text) if use_memory else input_text
|
|
228
|
+
|
|
229
|
+
contents = [
|
|
230
|
+
types.Content(
|
|
231
|
+
role="user",
|
|
232
|
+
parts=[types.Part.from_text(text=prompt)],
|
|
233
|
+
)
|
|
234
|
+
]
|
|
235
|
+
|
|
236
|
+
config = types.GenerateContentConfig(
|
|
237
|
+
thinking_config=types.ThinkingConfig(
|
|
238
|
+
thinking_budget=self.thinking_budget
|
|
239
|
+
),
|
|
240
|
+
system_instruction=[
|
|
241
|
+
types.Part.from_text(text=self.sys_prompt)
|
|
242
|
+
],
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
response_text = ""
|
|
246
|
+
|
|
247
|
+
if stream:
|
|
248
|
+
for chunk in self.client.models.generate_content_stream(
|
|
249
|
+
model=self.model,
|
|
250
|
+
contents=contents,
|
|
251
|
+
config=config,
|
|
252
|
+
):
|
|
253
|
+
if chunk.text:
|
|
254
|
+
print(chunk.text, end="", flush=True)
|
|
255
|
+
response_text += chunk.text
|
|
256
|
+
|
|
257
|
+
print()
|
|
258
|
+
|
|
259
|
+
else:
|
|
260
|
+
response = self.client.models.generate_content(
|
|
261
|
+
model=self.model,
|
|
262
|
+
contents=contents,
|
|
263
|
+
config=config,
|
|
264
|
+
)
|
|
265
|
+
response_text = response.text or ""
|
|
266
|
+
|
|
267
|
+
self.memory.append((input_text, response_text))
|
|
268
|
+
self.trim_memory()
|
|
269
|
+
|
|
270
|
+
return response_text
|
|
271
|
+
|
|
272
|
+
def __repr__(self) -> str:
|
|
273
|
+
return (
|
|
274
|
+
f"<{self.__class__.__name__} "
|
|
275
|
+
f"name={self.name!r} "
|
|
276
|
+
f"model={self.model!r} "
|
|
277
|
+
f"memory_window={self.memory_window!r} "
|
|
278
|
+
f"max_turns={self.max_turns!r} "
|
|
279
|
+
f"max_facts={self.max_facts!r}>"
|
|
280
|
+
)
|