mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-09-08 04:00:15 +00:00
Adds Google ADK (Agent Development Kit) support to Semantica. `semantica_kg_tools()` and `semantica_decision_tools()` expose entity/relation extraction, graph updates, and decision recording as ADK `FunctionTool`s. `SemanticaSessionService` implements ADK session storage on top of a Semantica `ContextGraph`, so session state, events, and knowledge graph data can live in the same graph instead of keeping sessions in memory. There were also a number of dependency and CI fixes needed to get the integration working reliably. `google-adk` is pinned to a range that avoids the CI `websockets` conflict, the deprecated `pinecone-client` dependency was replaced with `pinecone`, Windows-only dependencies now have the appropriate platform markers, and `requirements-ci.txt` was regenerated to match. A `pip-audit` pass also required updates to `google-adk` and `starlette` for known CVEs. Some unrelated `pyproject.toml` changes had slipped in during rebases, so the previous version, dependency bounds, `ingest-sap`/LangChain entries, and package-data settings were restored. A few bugs in the initial ADK implementation were fixed during review: * `extract_relations()` was calling `RelationExtractor.extract_entities()`, which doesn't exist on that extractor. The failure was being caught and returned in the tool's `error` field, leaving callers with an empty relation list. It now calls the correct extraction path. * The repo's top-level `mcp/` package shadowed the third-party `mcp` package imported by `google.adk`, causing `google.adk` imports to fail from a normal repo checkout. The local package was moved to `semantica_mcp/mcp/`. The MCP move needed a follow-up as well. `semantica/cli.py` and four existing tests were still importing from `mcp.*`, and the modules under `semantica_mcp/mcp/` still used the old absolute imports internally. `semantica_mcp` was also missing from the setuptools package include list and had no `__init__.py`, so it wouldn't have been included in an installed package. Those imports and packaging settings are fixed now. The session service and ADK tools also had a few other problems: * `list_sessions()` returned a plain list instead of ADK's `ListSessionsResponse`. The original import for that type doesn't work against the installed `google-adk` package, so it was silently falling back to a stub. `user_id` was also incorrectly required instead of being optional. * Session node IDs were built by joining `app_name`, `user_id`, and `session_id` with unescaped colons, which allowed different identities to produce the same graph node ID. Each component is now encoded before joining. * `kg_tools.py` and `decision_tools.py` each had their own lock registry and default graph instance. Sharing a graph between the two modules therefore didn't share the lock, and using both factories without an explicit graph produced two different defaults. The shared state now lives in one module used by both. * `add_to_graph` had a `TypeError` compatibility fallback that couldn't succeed with the current `RelationExtractor` API and could hide the original extraction error. That fallback was removed. * `append_event` persisted partial streaming events even though ADK's base session service skips them. * `get_session()` ignored its `config` argument, so `num_recent_events` and `after_timestamp` had no effect. * The async session-service methods performed synchronous graph scans while holding a `threading.RLock` on the event loop thread. That work now runs in worker threads with `asyncio.to_thread()` so a slow or contended graph operation doesn't block the loop. --- Co-authored-by: Zohaib Hassnain [109234410+ZohaibHassan16@users.noreply.github.com](mailto:109234410+ZohaibHassan16@users.noreply.github.com)
646 lines
20 KiB
Python
646 lines
20 KiB
Python
"""
|
|
Semantica-backed Google ADK session service.
|
|
|
|
Session metadata, state, and event history are represented as nodes in a
|
|
Semantica ContextGraph instead of being kept only in ADK's in-memory store.
|
|
|
|
Google ADK is an optional dependency.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import copy
|
|
import inspect
|
|
import threading
|
|
import urllib.parse
|
|
import uuid
|
|
from datetime import datetime
|
|
from typing import Any, List, Optional
|
|
|
|
try:
|
|
from google.adk.events import Event
|
|
from google.adk.sessions import BaseSessionService, Session
|
|
try:
|
|
from google.adk.sessions import ListSessionsResponse
|
|
except ImportError:
|
|
# Not every google-adk release re-exports ListSessionsResponse from
|
|
# the sessions package __init__; it always lives in
|
|
# base_session_service.
|
|
from google.adk.sessions.base_session_service import ListSessionsResponse
|
|
try:
|
|
from google.adk.sessions import GetSessionConfig
|
|
except ImportError:
|
|
from google.adk.sessions.base_session_service import GetSessionConfig
|
|
ADK_AVAILABLE = True
|
|
except (ImportError, ModuleNotFoundError):
|
|
ADK_AVAILABLE = False
|
|
BaseSessionService = object
|
|
Session = Any
|
|
Event = Any
|
|
ListSessionsResponse = Any
|
|
GetSessionConfig = Any
|
|
|
|
|
|
class SemanticaSessionService(BaseSessionService):
|
|
"""
|
|
Google ADK SessionService backed by a Semantica ContextGraph.
|
|
|
|
Graph structure:
|
|
|
|
ADKSession
|
|
|
|
|
+-- HAS_EVENT --> ADKEvent
|
|
|
|
Session metadata and state are stored in the ContextGraph node metadata.
|
|
"""
|
|
|
|
def __init__(self, graph: Optional[Any] = None) -> None:
|
|
if not ADK_AVAILABLE:
|
|
raise ImportError(
|
|
"Google ADK is required for SemanticaSessionService. "
|
|
"Install it with: pip install semantica[google-adk]"
|
|
)
|
|
|
|
super().__init__()
|
|
|
|
if graph is None:
|
|
from semantica.context import ContextGraph
|
|
|
|
graph = ContextGraph()
|
|
|
|
self.graph = graph
|
|
self._lock = threading.RLock()
|
|
|
|
# Graph helpers
|
|
|
|
@staticmethod
|
|
def _node_id(app_name: str, user_id: str, session_id: str) -> str:
|
|
"""Return the internal ContextGraph node ID for a session.
|
|
|
|
Each component is percent-encoded before joining so a ':' inside
|
|
app_name/user_id/session_id can never be mistaken for the
|
|
separator: without this, distinct identities such as
|
|
(app_name="tenant:A", user_id="alice") and
|
|
(app_name="tenant", user_id="A:alice") would collide on the same
|
|
node ID.
|
|
"""
|
|
parts = (
|
|
urllib.parse.quote(part, safe="")
|
|
for part in (app_name, user_id, session_id)
|
|
)
|
|
return "adk-session:" + ":".join(parts)
|
|
|
|
@staticmethod
|
|
def _event_node_id(event: Any) -> str:
|
|
"""Return the internal ContextGraph node ID for an event."""
|
|
event_id = getattr(event, "id", None)
|
|
|
|
if event_id:
|
|
return f"adk-event:{event_id}"
|
|
|
|
return f"adk-event:{uuid.uuid4()}"
|
|
|
|
@staticmethod
|
|
def _safe_dict(value: Any) -> dict:
|
|
"""Convert common Python/Pydantic objects into a dictionary."""
|
|
if value is None:
|
|
return {}
|
|
|
|
if isinstance(value, dict):
|
|
return copy.deepcopy(value)
|
|
|
|
if hasattr(value, "model_dump"):
|
|
try:
|
|
return copy.deepcopy(value.model_dump())
|
|
except Exception:
|
|
pass
|
|
|
|
if hasattr(value, "dict"):
|
|
try:
|
|
return copy.deepcopy(value.dict())
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
return {
|
|
key: copy.deepcopy(item)
|
|
for key, item in vars(value).items()
|
|
if not key.startswith("_")
|
|
}
|
|
except Exception:
|
|
return {}
|
|
|
|
@staticmethod
|
|
def _node_properties(node: Any) -> dict:
|
|
"""
|
|
Extract application properties from a ContextGraph node.
|
|
"""
|
|
if isinstance(node, dict):
|
|
metadata = node.get("metadata")
|
|
|
|
if isinstance(metadata, dict):
|
|
return copy.deepcopy(metadata)
|
|
|
|
properties = node.get("properties")
|
|
|
|
if isinstance(properties, dict):
|
|
return copy.deepcopy(properties)
|
|
|
|
return {}
|
|
|
|
metadata = getattr(node, "metadata", None)
|
|
|
|
if isinstance(metadata, dict):
|
|
return copy.deepcopy(metadata)
|
|
|
|
properties = getattr(node, "properties", None)
|
|
|
|
if isinstance(properties, dict):
|
|
return copy.deepcopy(properties)
|
|
|
|
return {}
|
|
|
|
def _find_session_node(
|
|
self,
|
|
app_name: str,
|
|
user_id: str,
|
|
session_id: str,
|
|
) -> Optional[Any]:
|
|
"""Find a session node by its logical ADK session ID."""
|
|
expected_node_id = self._node_id(app_name, user_id, session_id)
|
|
|
|
for node in self.graph.find_nodes() or []:
|
|
if not isinstance(node, dict):
|
|
continue
|
|
|
|
# Fast path: ContextGraph node ID.
|
|
if str(node.get("id")) == expected_node_id:
|
|
return node
|
|
|
|
# Fallback: logical ID stored in metadata.
|
|
metadata = node.get("metadata")
|
|
|
|
if (
|
|
isinstance(metadata, dict)
|
|
and str(metadata.get("session_id")) == str(session_id)
|
|
and str(metadata.get("app_name")) == str(app_name)
|
|
and str(metadata.get("user_id")) == str(user_id)
|
|
):
|
|
return node
|
|
|
|
return None
|
|
|
|
def _find_node_by_id(
|
|
self,
|
|
node_id: str,
|
|
) -> Optional[Any]:
|
|
"""Find a ContextGraph node by graph node ID."""
|
|
for node in self.graph.find_nodes() or []:
|
|
if isinstance(node, dict) and str(node.get("id")) == str(node_id):
|
|
return node
|
|
|
|
return None
|
|
|
|
# ------------------------------------------------------------------
|
|
# Event helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def _serialize_event(event: Any) -> dict:
|
|
"""Serialize an ADK Event into ContextGraph metadata."""
|
|
data = SemanticaSessionService._safe_dict(event)
|
|
|
|
for field in (
|
|
"id",
|
|
"invocation_id",
|
|
"author",
|
|
"timestamp",
|
|
"partial",
|
|
"turn_complete",
|
|
"branch",
|
|
):
|
|
if field not in data and hasattr(event, field):
|
|
value = getattr(event, field)
|
|
|
|
if isinstance(value, datetime):
|
|
value = value.isoformat()
|
|
|
|
data[field] = copy.deepcopy(value)
|
|
|
|
return data
|
|
|
|
def _event_nodes(
|
|
self,
|
|
app_name: str,
|
|
user_id: str,
|
|
session_id: str,
|
|
) -> List[Any]:
|
|
"""Return all event nodes connected to a session."""
|
|
session_node_id = self._node_id(app_name, user_id, session_id)
|
|
|
|
events: List[Any] = []
|
|
|
|
for edge in self.graph.find_edges() or []:
|
|
if not isinstance(edge, dict):
|
|
continue
|
|
|
|
if edge.get("source") != session_node_id:
|
|
continue
|
|
|
|
if edge.get("type") != "HAS_EVENT":
|
|
continue
|
|
|
|
target = edge.get("target")
|
|
|
|
if target is None:
|
|
continue
|
|
|
|
node = self._find_node_by_id(str(target))
|
|
|
|
if node is not None:
|
|
events.append(node)
|
|
|
|
return events
|
|
|
|
@staticmethod
|
|
def _event_timestamp(node: Any) -> str:
|
|
"""Return a sortable timestamp for an event node."""
|
|
properties = SemanticaSessionService._node_properties(node)
|
|
timestamp = properties.get("timestamp")
|
|
|
|
if timestamp is None:
|
|
return ""
|
|
|
|
return str(timestamp)
|
|
|
|
def _event_from_node(
|
|
self,
|
|
node: Any,
|
|
) -> Any:
|
|
"""
|
|
Reconstruct an ADK Event from its stored metadata.
|
|
"""
|
|
properties = self._node_properties(node)
|
|
|
|
graph_node_id = node.get("id") if isinstance(node, dict) else None
|
|
event_id = properties.get("id")
|
|
|
|
if not event_id and graph_node_id:
|
|
graph_node_id = str(graph_node_id)
|
|
if graph_node_id.startswith("adk-event:"):
|
|
event_id = graph_node_id[len("adk-event:"):]
|
|
|
|
if event_id:
|
|
properties["id"] = event_id
|
|
|
|
# ContextGraph-specific values should never become Event fields.
|
|
properties.pop("session_id", None)
|
|
properties.pop("app_name", None)
|
|
properties.pop("user_id", None)
|
|
|
|
try:
|
|
return Event(**properties)
|
|
except Exception:
|
|
return properties
|
|
|
|
# ------------------------------------------------------------------
|
|
# Session helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def _session_kwargs(
|
|
app_name: str,
|
|
user_id: str,
|
|
session_id: str,
|
|
state: Optional[dict],
|
|
events: Optional[List[Any]],
|
|
) -> dict:
|
|
"""Build kwargs for the ADK Session model."""
|
|
return {
|
|
"app_name": app_name,
|
|
"user_id": user_id,
|
|
"id": session_id,
|
|
"state": copy.deepcopy(state or {}),
|
|
"events": list(events or []),
|
|
}
|
|
|
|
def _session_from_node(
|
|
self,
|
|
node: Any,
|
|
) -> Session:
|
|
"""Reconstruct an ADK Session from a ContextGraph node."""
|
|
properties = self._node_properties(node)
|
|
|
|
session_id = str(properties.get("session_id") or "")
|
|
app_name = str(properties.get("app_name") or "")
|
|
user_id = str(properties.get("user_id") or "")
|
|
|
|
# Fallback to the graph node ID.
|
|
if not session_id:
|
|
graph_node_id = node.get("id") if isinstance(node, dict) else None
|
|
|
|
if graph_node_id:
|
|
graph_node_id = str(graph_node_id)
|
|
if graph_node_id.startswith("adk-session:"):
|
|
# Each component is percent-encoded by _node_id(), so
|
|
# splitting on ':' after the prefix always yields
|
|
# exactly 3 parts regardless of what characters the
|
|
# original app_name/user_id/session_id contained.
|
|
parts = graph_node_id[len("adk-session:"):].split(":")
|
|
if len(parts) == 3:
|
|
decoded = [urllib.parse.unquote(part) for part in parts]
|
|
app_name = app_name or decoded[0]
|
|
user_id = user_id or decoded[1]
|
|
session_id = decoded[2]
|
|
else:
|
|
session_id = graph_node_id[len("adk-session:"):]
|
|
else:
|
|
session_id = graph_node_id
|
|
|
|
state = properties.get("state") or {}
|
|
|
|
if not isinstance(state, dict):
|
|
state = {}
|
|
|
|
event_nodes = self._event_nodes(app_name, user_id, session_id)
|
|
event_nodes.sort(key=self._event_timestamp)
|
|
|
|
events = [self._event_from_node(node) for node in event_nodes]
|
|
|
|
return Session(
|
|
**self._session_kwargs(
|
|
app_name=app_name,
|
|
user_id=user_id,
|
|
session_id=session_id,
|
|
state=state,
|
|
events=events,
|
|
)
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# ADK SessionService implementation
|
|
# ------------------------------------------------------------------
|
|
|
|
async def create_session(
|
|
self,
|
|
*,
|
|
app_name: str,
|
|
user_id: str,
|
|
state: Optional[dict[str, Any]] = None,
|
|
session_id: Optional[str] = None,
|
|
) -> Session:
|
|
"""Create and persist an ADK session."""
|
|
return await asyncio.to_thread(
|
|
self._create_session_sync, app_name, user_id, state, session_id
|
|
)
|
|
|
|
def _create_session_sync(
|
|
self,
|
|
app_name: str,
|
|
user_id: str,
|
|
state: Optional[dict[str, Any]],
|
|
session_id: Optional[str],
|
|
) -> Session:
|
|
with self._lock:
|
|
session_id = session_id or str(uuid.uuid4())
|
|
|
|
if self._find_session_node(app_name, user_id, session_id) is not None:
|
|
raise ValueError(f"Session already exists: {session_id}")
|
|
|
|
self.graph.add_node(
|
|
node_id=self._node_id(app_name, user_id, session_id),
|
|
node_type="ADKSession",
|
|
app_name=app_name,
|
|
user_id=user_id,
|
|
session_id=session_id,
|
|
state=copy.deepcopy(state or {}),
|
|
created_at=datetime.now().isoformat(),
|
|
updated_at=datetime.now().isoformat(),
|
|
)
|
|
|
|
return Session(
|
|
**self._session_kwargs(
|
|
app_name=app_name,
|
|
user_id=user_id,
|
|
session_id=session_id,
|
|
state=state,
|
|
events=[],
|
|
)
|
|
)
|
|
|
|
async def get_session(
|
|
self,
|
|
*,
|
|
app_name: str,
|
|
user_id: str,
|
|
session_id: str,
|
|
config: Optional[GetSessionConfig] = None,
|
|
) -> Optional[Session]:
|
|
"""Retrieve an ADK session from ContextGraph."""
|
|
return await asyncio.to_thread(
|
|
self._get_session_sync, app_name, user_id, session_id, config
|
|
)
|
|
|
|
def _get_session_sync(
|
|
self,
|
|
app_name: str,
|
|
user_id: str,
|
|
session_id: str,
|
|
config: Optional[GetSessionConfig],
|
|
) -> Optional[Session]:
|
|
with self._lock:
|
|
node = self._find_session_node(app_name, user_id, session_id)
|
|
|
|
if node is None:
|
|
return None
|
|
|
|
properties = self._node_properties(node)
|
|
if properties.get("app_name") != app_name:
|
|
return None
|
|
if properties.get("user_id") != user_id:
|
|
return None
|
|
|
|
session = self._session_from_node(node)
|
|
|
|
# Bound the returned event history the same way ADK's own
|
|
# InMemorySessionService does, outside the lock since it only
|
|
# trims the already-built Session object.
|
|
if config:
|
|
if config.num_recent_events:
|
|
session.events = session.events[-config.num_recent_events:]
|
|
if config.after_timestamp:
|
|
i = len(session.events) - 1
|
|
while i >= 0:
|
|
if session.events[i].timestamp < config.after_timestamp:
|
|
break
|
|
i -= 1
|
|
if i >= 0:
|
|
session.events = session.events[i + 1:]
|
|
|
|
return session
|
|
|
|
async def append_event(
|
|
self,
|
|
session: Session,
|
|
event: Event,
|
|
) -> Event:
|
|
"""Persist an ADK event and associate it with a session."""
|
|
# ADK's own base implementation is a no-op for partial/streaming
|
|
# events (it returns before touching session.events or state), so
|
|
# a graph-backed session must not persist them either -- otherwise
|
|
# every intermediate chunk of a streamed response becomes a
|
|
# permanent event node.
|
|
if getattr(event, "partial", False):
|
|
return event
|
|
|
|
await asyncio.to_thread(self._append_event_sync, session, event)
|
|
return event
|
|
|
|
def _append_event_sync(self, session: Session, event: Event) -> None:
|
|
with self._lock:
|
|
session_id = str(session.id)
|
|
app_name = str(session.app_name)
|
|
user_id = str(session.user_id)
|
|
|
|
session_node = self._find_session_node(app_name, user_id, session_id)
|
|
|
|
if session_node is None:
|
|
raise ValueError(f"Session does not exist: {session_id}")
|
|
|
|
# Verify cross-tenant security
|
|
properties = self._node_properties(session_node)
|
|
if properties.get("app_name") != app_name or properties.get("user_id") != user_id:
|
|
raise ValueError("Cross-tenant session write denied: app_name or user_id mismatch.")
|
|
|
|
# Apply ADK in-memory event and state delta semantics. This
|
|
# runs inside asyncio.to_thread's worker thread, which has no
|
|
# event loop of its own, so a coroutine base implementation is
|
|
# driven with a private event loop scoped to this one call.
|
|
base_append = getattr(super(), "append_event", None)
|
|
if base_append is not None:
|
|
if inspect.iscoroutinefunction(base_append):
|
|
asyncio.run(base_append(session, event))
|
|
else:
|
|
base_append(session, event)
|
|
else:
|
|
if hasattr(session, "events"):
|
|
session.events.append(event)
|
|
|
|
event_node_id = self._event_node_id(event)
|
|
event_data = self._serialize_event(event)
|
|
|
|
self.graph.add_node(
|
|
node_id=event_node_id,
|
|
node_type="ADKEvent",
|
|
session_id=session_id,
|
|
**event_data,
|
|
)
|
|
|
|
self.graph.add_edge(
|
|
source_id=self._node_id(app_name, user_id, session_id),
|
|
target_id=event_node_id,
|
|
edge_type="HAS_EVENT",
|
|
)
|
|
|
|
# ContextGraph's supported mutation API is add_node_attribute().
|
|
self.graph.add_node_attribute(
|
|
self._node_id(app_name, user_id, session_id),
|
|
{
|
|
"state": self._safe_dict(getattr(session, "state", {})),
|
|
"updated_at": (datetime.now().isoformat()),
|
|
},
|
|
)
|
|
|
|
async def delete_session(
|
|
self,
|
|
*,
|
|
app_name: str,
|
|
user_id: str,
|
|
session_id: str,
|
|
) -> None:
|
|
"""Delete a session and all of its graph-backed events."""
|
|
await asyncio.to_thread(
|
|
self._delete_session_sync, app_name, user_id, session_id
|
|
)
|
|
|
|
def _delete_session_sync(
|
|
self,
|
|
app_name: str,
|
|
user_id: str,
|
|
session_id: str,
|
|
) -> None:
|
|
with self._lock:
|
|
session_node = self._find_session_node(app_name, user_id, session_id)
|
|
if session_node is None:
|
|
return
|
|
|
|
properties = self._node_properties(session_node)
|
|
|
|
if properties.get("app_name") != app_name:
|
|
return
|
|
if properties.get("user_id") != user_id:
|
|
return
|
|
|
|
session_node_id = self._node_id(app_name, user_id, session_id)
|
|
event_node_ids = []
|
|
|
|
for edge in self.graph.find_edges() or []:
|
|
if not isinstance(edge, dict):
|
|
continue
|
|
|
|
if (
|
|
edge.get("source") == session_node_id
|
|
and edge.get("type") == "HAS_EVENT"
|
|
and edge.get("target")
|
|
):
|
|
event_node_ids.append(str(edge["target"]))
|
|
|
|
for event_node_id in event_node_ids:
|
|
self.graph.purge_node(event_node_id)
|
|
|
|
self.graph.purge_node(session_node_id)
|
|
|
|
async def list_sessions(
|
|
self,
|
|
*,
|
|
app_name: str,
|
|
user_id: Optional[str] = None,
|
|
) -> ListSessionsResponse:
|
|
"""List sessions for an app, optionally scoped to one user."""
|
|
return await asyncio.to_thread(self._list_sessions_sync, app_name, user_id)
|
|
|
|
def _list_sessions_sync(
|
|
self,
|
|
app_name: str,
|
|
user_id: Optional[str],
|
|
) -> ListSessionsResponse:
|
|
with self._lock:
|
|
sessions: List[Session] = []
|
|
|
|
for node in self.graph.find_nodes(node_type="ADKSession") or []:
|
|
if not isinstance(node, dict):
|
|
continue
|
|
|
|
properties = self._node_properties(node)
|
|
|
|
if properties.get("app_name") != app_name:
|
|
continue
|
|
if user_id is not None and properties.get("user_id") != user_id:
|
|
continue
|
|
if not properties.get("session_id"):
|
|
continue
|
|
|
|
sessions.append(self._session_from_node(node))
|
|
|
|
# Return the wrapped ListSessionsResponse
|
|
if ListSessionsResponse is not Any and ListSessionsResponse is not object:
|
|
return ListSessionsResponse(sessions=sessions)
|
|
|
|
return sessions
|
|
|
|
|
|
__all__ = [
|
|
"ADK_AVAILABLE",
|
|
"SemanticaSessionService",
|
|
] |