gulupgulup's picture
Initial commit: MemGPT-lite v1
f5c4bfe
Raw History Blame Contribute Delete
10.6 kB
# src/agent.py
import os
from pathlib import Path
from google.adk.agents import LlmAgent
from google.adk.agents.callback_context import CallbackContext
from google.adk.models import LlmRequest
from google.adk.models.google_llm import Gemini
from google.adk.models.lite_llm import LiteLlm
from google.adk.runners import Runner
from google.adk.sessions import DatabaseSessionService, InMemorySessionService
from google.genai import types
from typing import Awaitable, Callable
from src.config import settings
from src.memory.core import get_block
from src.memory.recall import SqliteRecallMemoryService
from src.memory.archival import ArchivalStore
from src.tools.send import send_message
from src.tools.core_tools import core_memory_append, core_memory_replace
from src.tools.search_tools import recall_search, archival_insert, archival_search
APP_NAME = "memgpt_lite"
DEFAULT_USER_ID = "default"
_SYSTEM_PROMPT_PATH = Path(__file__).parent / "prompts" / "system.md"
# Storage mode: "disk" (default, dev + demo recording) uses on-disk SQLite
# + on-disk FAISS + DatabaseSessionService. "memory" (set on HF Space) uses
# InMemorySessionService + :memory: SQLite + non-persisted FAISS — container
# sleep naturally resets state, no cleanup code needed. See config.py for
# MEMGPT_DATA_DIR (disk mode's data root).
_STORAGE_MODE = os.environ.get("MEMGPT_STORAGE_MODE", "disk")
if _STORAGE_MODE not in ("disk", "memory"):
raise RuntimeError(
f"MEMGPT_STORAGE_MODE must be 'disk' or 'memory', got {_STORAGE_MODE!r}"
)
# Demo-abuse cap: bounds cost per LLM call. Default 1024 out; override with
# MEMGPT_MAX_OUTPUT_TOKENS env var (2048 for demo recording; higher for
# self-hosters). Documented in README §Deployment.
_MAX_OUTPUT_TOKENS = int(os.environ.get("MEMGPT_MAX_OUTPUT_TOKENS", "1024"))
if _STORAGE_MODE == "memory":
session_service = InMemorySessionService()
memory_service = SqliteRecallMemoryService(db_path=":memory:")
archival_store = ArchivalStore(
db_path=":memory:",
index_path=None,
dim=settings.embeddings.dim,
)
else:
session_service = DatabaseSessionService(
db_url=f"sqlite+aiosqlite:///{Path(settings.storage.sessions_db).as_posix()}"
)
memory_service = SqliteRecallMemoryService(db_path=settings.storage.recall_db)
archival_store = ArchivalStore(
db_path=settings.storage.archival_db,
index_path=settings.storage.archival_index,
dim=settings.embeddings.dim,
)
def _build_model():
if settings.model.provider == "gemini":
retry_config = types.HttpRetryOptions(
attempts=settings.model.retry.attempts,
exp_base=settings.model.retry.exp_base,
initial_delay=settings.model.retry.initial_delay,
http_status_codes=settings.model.retry.http_status_codes,
)
return Gemini(model=settings.model.name, retry_options=retry_config)
if settings.model.provider == "openrouter":
return LiteLlm(
model=settings.model.name,
num_retries=settings.model.retry.attempts,
base_url="https://openrouter.ai/api/v1",
)
raise ValueError(f"Unknown model provider: {settings.model.provider}")
def _inject_core_memory(
callback_context: CallbackContext, llm_request: LlmRequest
) -> None:
"""Append <persona> and <human> blocks to system_instruction each turn."""
state = callback_context._invocation_context.session.state
persona = get_block(state, "persona")
human = get_block(state, "human")
base = llm_request.config.system_instruction or ""
if persona:
base += f"\n\n<persona>\n{persona}\n</persona>"
if human:
base += f"\n\n<human>\n{human}\n</human>"
llm_request.config.system_instruction = base
async def _auto_save_to_memory(callback_context: CallbackContext) -> None:
"""Mirror every completed turn into recall memory."""
await callback_context._invocation_context.memory_service.add_session_to_memory(
callback_context._invocation_context.session
)
def build_agent() -> LlmAgent:
return LlmAgent(
model=_build_model(),
name="memgpt_lite",
description="Chatbot with tiered persistent memory (MemGPT-lite).",
instruction=_SYSTEM_PROMPT_PATH.read_text(encoding="utf-8"),
tools=[
send_message,
core_memory_append,
core_memory_replace,
recall_search,
archival_insert,
archival_search,
],
before_model_callback=_inject_core_memory,
after_agent_callback=_auto_save_to_memory,
# Demo-abuse cap: bounds cost per LLM call. Default 1024 out; override with
# MEMGPT_MAX_OUTPUT_TOKENS env var (2048 for demo recording;
# self-hosters can raise/remove). Documented in README §Deployment.
generate_content_config=types.GenerateContentConfig(
max_output_tokens=_MAX_OUTPUT_TOKENS,
),
)
def build_runner() -> Runner:
return Runner(
agent=build_agent(),
app_name=APP_NAME,
session_service=session_service,
memory_service=memory_service,
)
async def _ensure_session(session_id: str, user_id: str):
"""Get an existing session or create a new one.
Follows the notebook's try-create-except-get pattern: create_session
raises if the id is taken; get_session returns the existing one.
"""
try:
return await session_service.create_session(
app_name=APP_NAME, user_id=user_id, session_id=session_id
)
except Exception:
return await session_service.get_session(
app_name=APP_NAME, user_id=user_id, session_id=session_id
)
ConfirmCallback = Callable[[str, dict], Awaitable[bool]]
def _extract_reply(events) -> str:
"""Return the last send_message emission across the given events (may be '')."""
reply = ""
for event in events:
if not event.content or not event.content.parts:
continue
for part in event.content.parts:
fc = getattr(part, "function_call", None)
if fc is not None and fc.name == "send_message":
reply = fc.args.get("message", "")
return reply
async def start_turn(
runner: Runner,
user_text: str,
session_id: str = "default",
user_id: str = DEFAULT_USER_ID,
) -> dict:
"""Run phase 1 of a user turn. Returns a dict with reply + pending approval.
Return shape:
{
"reply": str, # from send_message in phase-1 events (may be "")
"pending": None | {
"approval_id": str,
"invocation_id": str,
"hint": str,
"payload": dict,
"session_id": str,
"user_id": str,
}
}
If ``pending`` is ``None``, the turn completed. Otherwise the invocation
is paused; the caller must invoke ``resume_turn(runner, pending, approved)``
to finish it.
"""
session = await _ensure_session(session_id, user_id)
query = types.Content(role="user", parts=[types.Part(text=user_text)])
events = []
async for event in runner.run_async(
user_id=user_id, session_id=session.id, new_message=query
):
events.append(event)
reply = _extract_reply(events)
approval = _check_for_approval(events)
if approval is None:
return {"reply": reply, "pending": None}
return {
"reply": reply,
"pending": {
**approval,
"session_id": session.id,
"user_id": user_id,
},
}
async def resume_turn(
runner: Runner,
pending: dict,
approved: bool,
) -> str:
"""Resume a paused invocation with an approval decision. Returns reply text."""
resume_msg = _build_approval_response(pending["approval_id"], approved)
events = []
async for event in runner.run_async(
user_id=pending["user_id"],
session_id=pending["session_id"],
new_message=resume_msg,
invocation_id=pending["invocation_id"],
):
events.append(event)
return _extract_reply(events)
async def run_turn(
runner: Runner,
user_text: str,
session_id: str = "default",
user_id: str = DEFAULT_USER_ID,
on_confirm: ConfirmCallback | None = None,
) -> str:
"""Convenience wrapper: start_turn, auto-resume if paused. For REPL/tests.
The Gradio UI in Step 18c uses start_turn + resume_turn directly instead,
because it needs to hand control back to the user for the yes/no reply.
"""
result = await start_turn(runner, user_text, session_id, user_id)
if result["pending"] is None:
return result["reply"]
approved = (
await on_confirm(result["pending"]["hint"], result["pending"]["payload"])
if on_confirm is not None
else False
)
resumed_reply = await resume_turn(runner, result["pending"], approved)
return resumed_reply if resumed_reply else result["reply"]
def _check_for_approval(events) -> dict | None:
"""Scan collected events for a confirmation request. Returns None if not paused.
ADK nests the confirmation data under a ``toolConfirmation`` object inside
``fc.args`` (alongside ``originalFunctionCall``); the hint and payload are
NOT at the top level. Inspected via scratch/inspect_confirmation_args.py —
banking the shape here so a future ADK bump has a diff to grep for.
"""
for event in events:
if not event.content or not event.content.parts:
continue
for part in event.content.parts:
fc = getattr(part, "function_call", None)
if fc is not None and fc.name == "adk_request_confirmation":
confirmation = fc.args.get("toolConfirmation", {}) or {}
return {
"approval_id": fc.id,
"invocation_id": event.invocation_id,
"hint": confirmation.get("hint", ""),
"payload": confirmation.get("payload", {}),
}
return
def _build_approval_response(approval_id: str, approved: bool) -> types.Content:
"""Build the FunctionResponse Content that resumes a paused invocation."""
response = types.FunctionResponse(
id=approval_id,
name="adk_request_confirmation",
response={"confirmed": approved},
)
return types.Content(role="user", parts=[types.Part(function_response=response)])