mirror of
https://github.com/hoshikawa2/agent_platform_oci.git
synced 2026-09-07 18:23:46 +00:00
Disclaimer best practicies Oracle for Security
This commit is contained in:
@@ -61,6 +61,16 @@ class Settings(BaseSettings):
|
||||
MEMORY_INJECT_RECENT_MESSAGES: bool = True
|
||||
MEMORY_INJECT_SUMMARY: bool = True
|
||||
|
||||
ENABLE_LONG_TERM_MEMORY: bool = False
|
||||
LONG_TERM_MEMORY_PROVIDER: Literal['memory','sqlite','autonomous','oracle'] = 'sqlite'
|
||||
LONG_TERM_MEMORY_SQLITE_PATH: str | None = None
|
||||
LONG_TERM_MEMORY_TABLE: str = 'agentfw_long_term_memory'
|
||||
LONG_TERM_MEMORY_ORACLE_TABLE: str | None = None
|
||||
LONG_TERM_MEMORY_MAX_CONTEXT_ITEMS: int = 20
|
||||
LONG_TERM_MEMORY_MIN_CONFIDENCE: float = 0.70
|
||||
LONG_TERM_MEMORY_AUTO_EXTRACT: bool = True
|
||||
LONG_TERM_MEMORY_INJECT_CONTEXT: bool = True
|
||||
|
||||
# LangGraph enterprise checkpointing
|
||||
ENABLE_RESILIENT_CHECKPOINTER: bool = True
|
||||
ENABLE_CHECKPOINT_INTEGRITY: bool = True
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_PATTERNS = [
|
||||
('identity', 'preferred_name', re.compile(r'\b(?:me chame de|pode me chamar de|meu nome preferido é)\s+([A-Za-zÀ-ÿ][A-Za-zÀ-ÿ0-9 _-]{1,40})', re.I)),
|
||||
('preference', 'preferred_language', re.compile(r'\b(?:minha linguagem preferida é|prefiro programar em)\s+(Python|Java|JavaScript|TypeScript|Go|Rust|C#|C\+\+)\b', re.I)),
|
||||
('project', 'current_project', re.compile(r'\b(?:meu projeto atual se chama|estou trabalhando no projeto|o projeto se chama)\s+([A-Za-zÀ-ÿ0-9._ -]{2,60})', re.I)),
|
||||
('constraint', 'meeting_restriction', re.compile(r'\b(não (?:marque|agende) reuniões?[^.!?\n]{3,120})', re.I)),
|
||||
('preference', 'communication_style', re.compile(r'\b(?:prefiro respostas|responda de forma)\s+(curtas?|detalhadas?|objetivas?|técnicas?|didáticas?)', re.I)),
|
||||
]
|
||||
|
||||
def extract_long_term_memory(text: str, min_confidence: float = 0.70) -> list[dict[str, Any]]:
|
||||
normalized = ' '.join((text or '').split())
|
||||
output: list[dict[str, Any]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for category, key, pattern in _PATTERNS:
|
||||
match = pattern.search(normalized)
|
||||
if not match or (category, key) in seen:
|
||||
continue
|
||||
seen.add((category, key))
|
||||
confidence = 0.98
|
||||
if confidence >= min_confidence:
|
||||
output.append({'category': category, 'key': key, 'value': match.group(1).strip(' .,;:'), 'confidence': confidence, 'metadata': {'extractor': 'regex-v1'}})
|
||||
return output
|
||||
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from .long_term_extractor import extract_long_term_memory
|
||||
from .long_term_store import create_long_term_memory_store
|
||||
|
||||
logger = logging.getLogger('agent_framework.memory.long_term')
|
||||
|
||||
class LongTermMemoryManager:
|
||||
def __init__(self, settings, store=None, telemetry=None):
|
||||
self.settings = settings
|
||||
self.store = store or create_long_term_memory_store(settings)
|
||||
self.telemetry = telemetry
|
||||
|
||||
@property
|
||||
def enabled(self):
|
||||
return bool(getattr(self.settings, 'ENABLE_LONG_TERM_MEMORY', False))
|
||||
|
||||
def identity(self, state):
|
||||
context = state.get('context') or {}
|
||||
session = context.get('session') or {}
|
||||
business = context.get('business_context') or state.get('business_context') or {}
|
||||
metadata = session.get('metadata') or {}
|
||||
tenant = str(state.get('tenant_id') or session.get('tenant_id') or 'default')
|
||||
agent = str(state.get('agent_id') or state.get('route') or session.get('active_agent') or 'default')
|
||||
subject = business.get('customer_key') or state.get('customer_key') or context.get('user_id') or session.get('user_id') or metadata.get('customer_key')
|
||||
return tenant, agent, str(subject) if subject else None
|
||||
|
||||
async def load(self, state):
|
||||
if not self.enabled:
|
||||
return []
|
||||
tenant, agent, subject = self.identity(state)
|
||||
if not subject:
|
||||
return []
|
||||
try:
|
||||
return await self.store.search(tenant_id=tenant, agent_id=agent, subject_key=subject, limit=int(getattr(self.settings, 'LONG_TERM_MEMORY_MAX_CONTEXT_ITEMS', 20)))
|
||||
except Exception:
|
||||
logger.exception('Falha não crítica ao carregar LTM')
|
||||
return []
|
||||
|
||||
async def persist_turn(self, state):
|
||||
if not self.enabled or not bool(getattr(self.settings, 'LONG_TERM_MEMORY_AUTO_EXTRACT', True)):
|
||||
return {'saved': 0, 'enabled': self.enabled}
|
||||
tenant, agent, subject = self.identity(state)
|
||||
if not subject:
|
||||
return {'saved': 0, 'warning': 'customer_key ausente'}
|
||||
text = str(state.get('sanitized_input') or state.get('user_text') or '')
|
||||
candidates = extract_long_term_memory(text, float(getattr(self.settings, 'LONG_TERM_MEMORY_MIN_CONFIDENCE', 0.70)))
|
||||
try:
|
||||
saved = await self.store.upsert_many(tenant_id=tenant, agent_id=agent, subject_key=subject, items=candidates, source_session_id=str(state.get('conversation_key') or state.get('session_id') or ''), source_message_id=str((state.get('context') or {}).get('message_id') or ''))
|
||||
return {'saved': len(saved), 'items': [item.to_dict() for item in saved]}
|
||||
except Exception as exc:
|
||||
logger.exception('Falha não crítica ao persistir LTM')
|
||||
return {'saved': 0, 'error': str(exc)}
|
||||
|
||||
def render(self, items):
|
||||
if not items:
|
||||
return ''
|
||||
lines = ['Memórias duráveis relevantes do usuário atual:']
|
||||
lines.extend(f'- {item.key}: {item.value}' for item in items)
|
||||
lines.extend(['Use somente estas memórias; não invente lembranças.', 'A mensagem atual prevalece se houver conflito.'])
|
||||
return '\n'.join(lines)
|
||||
|
||||
def create_long_term_memory_manager(settings, telemetry=None):
|
||||
return LongTermMemoryManager(settings, telemetry=telemetry)
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LongTermMemoryItem:
|
||||
memory_id: str
|
||||
tenant_id: str
|
||||
agent_id: str
|
||||
subject_key: str
|
||||
category: str
|
||||
key: str
|
||||
value: str
|
||||
confidence: float = 1.0
|
||||
source_session_id: str | None = None
|
||||
source_message_id: str | None = None
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
updated_at: str = field(default_factory=utc_now)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
@@ -0,0 +1,546 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol, Sequence
|
||||
|
||||
from .long_term_models import LongTermMemoryItem, utc_now
|
||||
|
||||
|
||||
class LongTermMemoryStore(Protocol):
|
||||
async def upsert_many(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
subject_key: str,
|
||||
items: Sequence[dict[str, Any]],
|
||||
source_session_id: str | None = None,
|
||||
source_message_id: str | None = None,
|
||||
) -> list[LongTermMemoryItem]: ...
|
||||
|
||||
async def search(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
subject_key: str,
|
||||
limit: int = 20,
|
||||
) -> list[LongTermMemoryItem]: ...
|
||||
|
||||
|
||||
class InMemoryLongTermMemoryStore:
|
||||
def __init__(self):
|
||||
self._items: dict[tuple[str, str, str, str, str], LongTermMemoryItem] = {}
|
||||
|
||||
async def upsert_many(self, **kwargs):
|
||||
saved = []
|
||||
now = utc_now()
|
||||
for raw in kwargs["items"]:
|
||||
key = (
|
||||
kwargs["tenant_id"],
|
||||
kwargs["agent_id"],
|
||||
kwargs["subject_key"],
|
||||
str(raw.get("category") or "fact"),
|
||||
str(raw.get("key") or ""),
|
||||
)
|
||||
if not key[-1] or not raw.get("value"):
|
||||
continue
|
||||
old = self._items.get(key)
|
||||
item = LongTermMemoryItem(
|
||||
old.memory_id if old else str(uuid.uuid4()),
|
||||
key[0], key[1], key[2], key[3], key[4],
|
||||
str(raw["value"]),
|
||||
float(raw.get("confidence", 1.0)),
|
||||
kwargs.get("source_session_id"),
|
||||
kwargs.get("source_message_id"),
|
||||
old.created_at if old else now,
|
||||
now,
|
||||
dict(raw.get("metadata") or {}),
|
||||
)
|
||||
self._items[key] = item
|
||||
saved.append(item)
|
||||
return saved
|
||||
|
||||
async def search(self, *, tenant_id, agent_id, subject_key, limit=20):
|
||||
values = [
|
||||
value for key, value in self._items.items()
|
||||
if key[:3] == (tenant_id, agent_id, subject_key)
|
||||
]
|
||||
return sorted(
|
||||
values,
|
||||
key=lambda item: (item.confidence, item.updated_at),
|
||||
reverse=True,
|
||||
)[:limit]
|
||||
|
||||
|
||||
class SQLiteLongTermMemoryStore:
|
||||
def __init__(
|
||||
self,
|
||||
path: str = "./data/agent_framework.db",
|
||||
table: str = "agentfw_long_term_memory",
|
||||
):
|
||||
self.path = str(path)
|
||||
self.table = _validate_identifier(table, upper=False)
|
||||
Path(self.path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self._ready = False
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def _connect(self):
|
||||
return sqlite3.connect(self.path)
|
||||
|
||||
def _init_sync(self):
|
||||
sql = f"""CREATE TABLE IF NOT EXISTS {self.table} (
|
||||
memory_id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, agent_id TEXT NOT NULL,
|
||||
subject_key TEXT NOT NULL, category TEXT NOT NULL, memory_key TEXT NOT NULL,
|
||||
memory_value TEXT NOT NULL, confidence REAL NOT NULL, source_session_id TEXT,
|
||||
source_message_id TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||
metadata_json TEXT, UNIQUE(tenant_id,agent_id,subject_key,category,memory_key))"""
|
||||
with self._connect() as db:
|
||||
db.execute(sql)
|
||||
db.execute(
|
||||
f"CREATE INDEX IF NOT EXISTS idx_{self.table}_subject "
|
||||
f"ON {self.table}(tenant_id,agent_id,subject_key,updated_at)"
|
||||
)
|
||||
|
||||
async def _ensure(self):
|
||||
if self._ready:
|
||||
return
|
||||
async with self._lock:
|
||||
if not self._ready:
|
||||
await asyncio.to_thread(self._init_sync)
|
||||
self._ready = True
|
||||
|
||||
def _upsert_sync(
|
||||
self, tenant_id, agent_id, subject_key, items,
|
||||
source_session_id, source_message_id,
|
||||
):
|
||||
now = utc_now()
|
||||
saved = []
|
||||
with self._connect() as db:
|
||||
for raw in items:
|
||||
category = str(raw.get("category") or "fact").lower()
|
||||
key = str(raw.get("key") or "").lower()
|
||||
value = str(raw.get("value") or "").strip()
|
||||
if not key or not value:
|
||||
continue
|
||||
row = db.execute(
|
||||
f"SELECT memory_id,created_at FROM {self.table} "
|
||||
"WHERE tenant_id=? AND agent_id=? AND subject_key=? "
|
||||
"AND category=? AND memory_key=?",
|
||||
(tenant_id, agent_id, subject_key, category, key),
|
||||
).fetchone()
|
||||
memory_id = row[0] if row else str(uuid.uuid4())
|
||||
created_at = row[1] if row else now
|
||||
confidence = float(raw.get("confidence", 1.0))
|
||||
metadata = dict(raw.get("metadata") or {})
|
||||
db.execute(
|
||||
f"""INSERT INTO {self.table}(
|
||||
memory_id,tenant_id,agent_id,subject_key,category,memory_key,
|
||||
memory_value,confidence,source_session_id,source_message_id,
|
||||
created_at,updated_at,metadata_json)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(tenant_id,agent_id,subject_key,category,memory_key)
|
||||
DO UPDATE SET memory_value=excluded.memory_value,
|
||||
confidence=excluded.confidence,
|
||||
source_session_id=excluded.source_session_id,
|
||||
source_message_id=excluded.source_message_id,
|
||||
updated_at=excluded.updated_at,
|
||||
metadata_json=excluded.metadata_json""",
|
||||
(
|
||||
memory_id, tenant_id, agent_id, subject_key, category, key,
|
||||
value, confidence, source_session_id, source_message_id,
|
||||
created_at, now, json.dumps(metadata, ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
saved.append(LongTermMemoryItem(
|
||||
memory_id, tenant_id, agent_id, subject_key, category, key,
|
||||
value, confidence, source_session_id, source_message_id,
|
||||
created_at, now, metadata,
|
||||
))
|
||||
return saved
|
||||
|
||||
async def upsert_many(self, **kwargs):
|
||||
await self._ensure()
|
||||
return await asyncio.to_thread(
|
||||
self._upsert_sync,
|
||||
kwargs["tenant_id"], kwargs["agent_id"], kwargs["subject_key"],
|
||||
list(kwargs["items"]), kwargs.get("source_session_id"),
|
||||
kwargs.get("source_message_id"),
|
||||
)
|
||||
|
||||
def _search_sync(self, tenant_id, agent_id, subject_key, limit):
|
||||
with self._connect() as db:
|
||||
rows = db.execute(
|
||||
f"SELECT memory_id,tenant_id,agent_id,subject_key,category,memory_key,"
|
||||
f"memory_value,confidence,source_session_id,source_message_id,"
|
||||
f"created_at,updated_at,metadata_json FROM {self.table} "
|
||||
"WHERE tenant_id=? AND agent_id=? AND subject_key=? "
|
||||
"ORDER BY confidence DESC,updated_at DESC LIMIT ?",
|
||||
(tenant_id, agent_id, subject_key, int(limit)),
|
||||
).fetchall()
|
||||
return [
|
||||
LongTermMemoryItem(*row[:12], metadata=json.loads(row[12] or "{}"))
|
||||
for row in rows
|
||||
]
|
||||
|
||||
async def search(self, **kwargs):
|
||||
await self._ensure()
|
||||
return await asyncio.to_thread(
|
||||
self._search_sync,
|
||||
kwargs["tenant_id"], kwargs["agent_id"], kwargs["subject_key"],
|
||||
kwargs.get("limit", 20),
|
||||
)
|
||||
|
||||
|
||||
def _validate_identifier(value: str, *, upper: bool = True) -> str:
|
||||
identifier = str(value or "").strip()
|
||||
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_$#]{0,127}", identifier):
|
||||
raise ValueError(f"Invalid SQL identifier: {value!r}")
|
||||
return identifier.upper() if upper else identifier
|
||||
|
||||
|
||||
def _as_iso(value: Any) -> str:
|
||||
if isinstance(value, datetime):
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return value.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
def _load_json(value: Any) -> dict[str, Any]:
|
||||
if value is None:
|
||||
return {}
|
||||
if hasattr(value, "read"):
|
||||
value = value.read()
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode("utf-8")
|
||||
try:
|
||||
loaded = json.loads(value)
|
||||
return loaded if isinstance(loaded, dict) else {}
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
class OracleAutonomousLongTermMemoryStore:
|
||||
"""Long-Term Memory provider for Oracle Autonomous Database.
|
||||
|
||||
The implementation uses python-oracledb in thin mode and reuses the
|
||||
framework's ADB_* settings. Synchronous database operations run in worker
|
||||
threads so FastAPI/LangGraph's event loop is not blocked.
|
||||
"""
|
||||
|
||||
def __init__(self, settings):
|
||||
self.user = str(getattr(settings, "ADB_USER", "") or "")
|
||||
self.password = str(getattr(settings, "ADB_PASSWORD", "") or "")
|
||||
self.dsn = str(getattr(settings, "ADB_DSN", "") or "")
|
||||
self.wallet_location = getattr(settings, "ADB_WALLET_LOCATION", None)
|
||||
self.wallet_password = getattr(settings, "ADB_WALLET_PASSWORD", None)
|
||||
default_table = (
|
||||
f"{getattr(settings, 'ADB_TABLE_PREFIX', 'AGENTFW')}_LONG_TERM_MEMORY"
|
||||
)
|
||||
configured_table = (
|
||||
getattr(settings, "LONG_TERM_MEMORY_ORACLE_TABLE", None)
|
||||
or default_table
|
||||
)
|
||||
self.table = _validate_identifier(configured_table)
|
||||
self.index_name = _validate_identifier(f"IX_{self.table}_SUBJECT")
|
||||
self.constraint_name = _validate_identifier(f"UQ_{self.table}_FACT")
|
||||
self._ready = False
|
||||
self._lock = asyncio.Lock()
|
||||
if not self.user or not self.password or not self.dsn:
|
||||
raise RuntimeError(
|
||||
"ADB_USER, ADB_PASSWORD and ADB_DSN are required when "
|
||||
"LONG_TERM_MEMORY_PROVIDER is autonomous/oracle"
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def _connect(self):
|
||||
try:
|
||||
import oracledb
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"python-oracledb is required for the Autonomous Long-Term "
|
||||
"Memory provider. Install it with: pip install oracledb"
|
||||
) from exc
|
||||
|
||||
oracledb.defaults.fetch_lobs = False
|
||||
kwargs: dict[str, Any] = {}
|
||||
if self.wallet_location:
|
||||
kwargs["config_dir"] = self.wallet_location
|
||||
kwargs["wallet_location"] = self.wallet_location
|
||||
if self.wallet_password:
|
||||
kwargs["wallet_password"] = self.wallet_password
|
||||
connection = oracledb.connect(
|
||||
user=self.user,
|
||||
password=self.password,
|
||||
dsn=self.dsn,
|
||||
**kwargs,
|
||||
)
|
||||
try:
|
||||
yield connection
|
||||
connection.commit()
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
@staticmethod
|
||||
def _ignore_already_exists(cursor, ddl: str) -> None:
|
||||
try:
|
||||
cursor.execute(ddl)
|
||||
except Exception as exc:
|
||||
message = str(exc)
|
||||
if "ORA-00955" in message or "ORA-01408" in message:
|
||||
return
|
||||
raise
|
||||
|
||||
def _init_sync(self) -> None:
|
||||
with self._connect() as connection:
|
||||
cursor = connection.cursor()
|
||||
self._ignore_already_exists(cursor, f"""
|
||||
CREATE TABLE {self.table} (
|
||||
MEMORY_ID VARCHAR2(36) PRIMARY KEY,
|
||||
TENANT_ID VARCHAR2(128) NOT NULL,
|
||||
AGENT_ID VARCHAR2(128) NOT NULL,
|
||||
SUBJECT_KEY VARCHAR2(512) NOT NULL,
|
||||
CATEGORY VARCHAR2(128) NOT NULL,
|
||||
MEMORY_KEY VARCHAR2(256) NOT NULL,
|
||||
MEMORY_VALUE CLOB NOT NULL,
|
||||
CONFIDENCE NUMBER(5,4) DEFAULT 1 NOT NULL,
|
||||
SOURCE_SESSION_ID VARCHAR2(512),
|
||||
SOURCE_MESSAGE_ID VARCHAR2(256),
|
||||
CREATED_AT TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
UPDATED_AT TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
METADATA_JSON CLOB CHECK (METADATA_JSON IS JSON),
|
||||
CONSTRAINT {self.constraint_name} UNIQUE (
|
||||
TENANT_ID, AGENT_ID, SUBJECT_KEY, CATEGORY, MEMORY_KEY
|
||||
)
|
||||
)
|
||||
""")
|
||||
self._ignore_already_exists(cursor, f"""
|
||||
CREATE INDEX {self.index_name}
|
||||
ON {self.table} (
|
||||
TENANT_ID, AGENT_ID, SUBJECT_KEY, UPDATED_AT DESC
|
||||
)
|
||||
""")
|
||||
|
||||
async def _ensure(self) -> None:
|
||||
if self._ready:
|
||||
return
|
||||
async with self._lock:
|
||||
if not self._ready:
|
||||
await asyncio.to_thread(self._init_sync)
|
||||
self._ready = True
|
||||
|
||||
def _find_existing(
|
||||
self, cursor, tenant_id: str, agent_id: str, subject_key: str,
|
||||
category: str, memory_key: str,
|
||||
) -> tuple[str, Any] | None:
|
||||
cursor.execute(
|
||||
f"""SELECT MEMORY_ID, CREATED_AT FROM {self.table}
|
||||
WHERE TENANT_ID = :tenant_id
|
||||
AND AGENT_ID = :agent_id
|
||||
AND SUBJECT_KEY = :subject_key
|
||||
AND CATEGORY = :category
|
||||
AND MEMORY_KEY = :memory_key""",
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
subject_key=subject_key,
|
||||
category=category,
|
||||
memory_key=memory_key,
|
||||
)
|
||||
return cursor.fetchone()
|
||||
|
||||
def _upsert_sync(
|
||||
self, tenant_id: str, agent_id: str, subject_key: str,
|
||||
items: Sequence[dict[str, Any]], source_session_id: str | None,
|
||||
source_message_id: str | None,
|
||||
) -> list[LongTermMemoryItem]:
|
||||
now = datetime.now(timezone.utc)
|
||||
saved: list[LongTermMemoryItem] = []
|
||||
with self._connect() as connection:
|
||||
cursor = connection.cursor()
|
||||
for raw in items:
|
||||
category = str(raw.get("category") or "fact").strip().lower()
|
||||
memory_key = str(raw.get("key") or "").strip().lower()
|
||||
value = str(raw.get("value") or "").strip()
|
||||
if not memory_key or not value:
|
||||
continue
|
||||
|
||||
existing = self._find_existing(
|
||||
cursor, tenant_id, agent_id, subject_key,
|
||||
category, memory_key,
|
||||
)
|
||||
memory_id = str(existing[0]) if existing else str(uuid.uuid4())
|
||||
created_at = existing[1] if existing else now
|
||||
confidence = float(raw.get("confidence", 1.0))
|
||||
metadata = dict(raw.get("metadata") or {})
|
||||
metadata_json = json.dumps(metadata, ensure_ascii=False, default=str)
|
||||
|
||||
cursor.execute(f"""
|
||||
MERGE INTO {self.table} target
|
||||
USING (
|
||||
SELECT
|
||||
:tenant_id AS TENANT_ID,
|
||||
:agent_id AS AGENT_ID,
|
||||
:subject_key AS SUBJECT_KEY,
|
||||
:category AS CATEGORY,
|
||||
:memory_key AS MEMORY_KEY
|
||||
FROM dual
|
||||
) source
|
||||
ON (
|
||||
target.TENANT_ID = source.TENANT_ID
|
||||
AND target.AGENT_ID = source.AGENT_ID
|
||||
AND target.SUBJECT_KEY = source.SUBJECT_KEY
|
||||
AND target.CATEGORY = source.CATEGORY
|
||||
AND target.MEMORY_KEY = source.MEMORY_KEY
|
||||
)
|
||||
WHEN MATCHED THEN UPDATE SET
|
||||
target.MEMORY_VALUE = :memory_value,
|
||||
target.CONFIDENCE = :confidence,
|
||||
target.SOURCE_SESSION_ID = :source_session_id,
|
||||
target.SOURCE_MESSAGE_ID = :source_message_id,
|
||||
target.UPDATED_AT = :updated_at,
|
||||
target.METADATA_JSON = :metadata_json
|
||||
WHEN NOT MATCHED THEN INSERT (
|
||||
MEMORY_ID, TENANT_ID, AGENT_ID, SUBJECT_KEY,
|
||||
CATEGORY, MEMORY_KEY, MEMORY_VALUE, CONFIDENCE,
|
||||
SOURCE_SESSION_ID, SOURCE_MESSAGE_ID,
|
||||
CREATED_AT, UPDATED_AT, METADATA_JSON
|
||||
) VALUES (
|
||||
:memory_id, :tenant_id, :agent_id, :subject_key,
|
||||
:category, :memory_key, :memory_value, :confidence,
|
||||
:source_session_id, :source_message_id,
|
||||
:created_at, :updated_at, :metadata_json
|
||||
)
|
||||
""", {
|
||||
"memory_id": memory_id,
|
||||
"tenant_id": tenant_id,
|
||||
"agent_id": agent_id,
|
||||
"subject_key": subject_key,
|
||||
"category": category,
|
||||
"memory_key": memory_key,
|
||||
"memory_value": value,
|
||||
"confidence": confidence,
|
||||
"source_session_id": source_session_id,
|
||||
"source_message_id": source_message_id,
|
||||
"created_at": created_at,
|
||||
"updated_at": now,
|
||||
"metadata_json": metadata_json,
|
||||
})
|
||||
saved.append(LongTermMemoryItem(
|
||||
memory_id=memory_id,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
subject_key=subject_key,
|
||||
category=category,
|
||||
key=memory_key,
|
||||
value=value,
|
||||
confidence=confidence,
|
||||
source_session_id=source_session_id,
|
||||
source_message_id=source_message_id,
|
||||
created_at=_as_iso(created_at),
|
||||
updated_at=_as_iso(now),
|
||||
metadata=metadata,
|
||||
))
|
||||
return saved
|
||||
|
||||
async def upsert_many(self, **kwargs):
|
||||
await self._ensure()
|
||||
return await asyncio.to_thread(
|
||||
self._upsert_sync,
|
||||
kwargs["tenant_id"],
|
||||
kwargs["agent_id"],
|
||||
kwargs["subject_key"],
|
||||
list(kwargs["items"]),
|
||||
kwargs.get("source_session_id"),
|
||||
kwargs.get("source_message_id"),
|
||||
)
|
||||
|
||||
def _search_sync(
|
||||
self, tenant_id: str, agent_id: str, subject_key: str, limit: int,
|
||||
) -> list[LongTermMemoryItem]:
|
||||
safe_limit = max(1, min(int(limit), 500))
|
||||
with self._connect() as connection:
|
||||
cursor = connection.cursor()
|
||||
cursor.execute(f"""
|
||||
SELECT
|
||||
MEMORY_ID, TENANT_ID, AGENT_ID, SUBJECT_KEY,
|
||||
CATEGORY, MEMORY_KEY, MEMORY_VALUE, CONFIDENCE,
|
||||
SOURCE_SESSION_ID, SOURCE_MESSAGE_ID,
|
||||
CREATED_AT, UPDATED_AT, METADATA_JSON
|
||||
FROM {self.table}
|
||||
WHERE TENANT_ID = :tenant_id
|
||||
AND AGENT_ID = :agent_id
|
||||
AND SUBJECT_KEY = :subject_key
|
||||
ORDER BY CONFIDENCE DESC, UPDATED_AT DESC
|
||||
FETCH FIRST {safe_limit} ROWS ONLY
|
||||
""", {
|
||||
"tenant_id": tenant_id,
|
||||
"agent_id": agent_id,
|
||||
"subject_key": subject_key,
|
||||
})
|
||||
rows = cursor.fetchall()
|
||||
|
||||
result: list[LongTermMemoryItem] = []
|
||||
for row in rows:
|
||||
result.append(LongTermMemoryItem(
|
||||
memory_id=str(row[0]),
|
||||
tenant_id=str(row[1]),
|
||||
agent_id=str(row[2]),
|
||||
subject_key=str(row[3]),
|
||||
category=str(row[4]),
|
||||
key=str(row[5]),
|
||||
value=str(row[6]),
|
||||
confidence=float(row[7]),
|
||||
source_session_id=str(row[8]) if row[8] is not None else None,
|
||||
source_message_id=str(row[9]) if row[9] is not None else None,
|
||||
created_at=_as_iso(row[10]),
|
||||
updated_at=_as_iso(row[11]),
|
||||
metadata=_load_json(row[12]),
|
||||
))
|
||||
return result
|
||||
|
||||
async def search(self, **kwargs):
|
||||
await self._ensure()
|
||||
return await asyncio.to_thread(
|
||||
self._search_sync,
|
||||
kwargs["tenant_id"],
|
||||
kwargs["agent_id"],
|
||||
kwargs["subject_key"],
|
||||
kwargs.get("limit", 20),
|
||||
)
|
||||
|
||||
|
||||
AutonomousLongTermMemoryStore = OracleAutonomousLongTermMemoryStore
|
||||
|
||||
|
||||
def create_long_term_memory_store(settings):
|
||||
provider = str(
|
||||
getattr(settings, "LONG_TERM_MEMORY_PROVIDER", "sqlite")
|
||||
).strip().lower()
|
||||
if provider == "memory":
|
||||
return InMemoryLongTermMemoryStore()
|
||||
if provider in {"autonomous", "oracle"}:
|
||||
return OracleAutonomousLongTermMemoryStore(settings)
|
||||
if provider != "sqlite":
|
||||
raise ValueError(
|
||||
"Unsupported LONG_TERM_MEMORY_PROVIDER: "
|
||||
f"{provider!r}. Expected memory, sqlite, autonomous or oracle."
|
||||
)
|
||||
path = (
|
||||
getattr(settings, "LONG_TERM_MEMORY_SQLITE_PATH", None)
|
||||
or getattr(settings, "SQLITE_DB_PATH", "./data/agent_framework.db")
|
||||
)
|
||||
return SQLiteLongTermMemoryStore(
|
||||
path,
|
||||
getattr(settings, "LONG_TERM_MEMORY_TABLE", "agentfw_long_term_memory"),
|
||||
)
|
||||
@@ -817,6 +817,16 @@ class AgentRuntimeMixin:
|
||||
state["memory_context"] = memory_context
|
||||
state["memory_context_metadata"] = memory_context.metadata
|
||||
|
||||
if bool(getattr(settings, "ENABLE_LONG_TERM_MEMORY", False)):
|
||||
manager = getattr(self, "long_term_memory_manager", None)
|
||||
if manager is None:
|
||||
from agent_framework.memory.long_term_memory import create_long_term_memory_manager
|
||||
manager = create_long_term_memory_manager(settings, telemetry=getattr(self, "telemetry", None))
|
||||
self.long_term_memory_manager = manager
|
||||
items = await manager.load(state)
|
||||
state["long_term_memories"] = [item.to_dict() for item in items]
|
||||
state["long_term_memory_context"] = manager.render(items)
|
||||
|
||||
if memory_context.compressed:
|
||||
await self._emit_ic(
|
||||
"IC.MEMORY_COMPRESSION_TRIGGERED",
|
||||
@@ -891,6 +901,8 @@ class AgentRuntimeMixin:
|
||||
runtime = self.get_runtime_context(state)
|
||||
sections = []
|
||||
sections.extend(self._render_memory_sections(state))
|
||||
if bool(getattr(getattr(self, "settings", None), "LONG_TERM_MEMORY_INJECT_CONTEXT", True)) and state.get("long_term_memory_context"):
|
||||
sections.append(str(state["long_term_memory_context"]))
|
||||
sections.extend([
|
||||
f"Mensagem do usuário:\n{user_text if user_text is not None else runtime.sanitized_input}",
|
||||
f"Intent/rota escolhidos pelo framework:\nintent={state.get('intent')} route={state.get('route')}",
|
||||
|
||||
Reference in New Issue
Block a user