RemanenetSpy
fix(agent): loosen memory retrieval threshold to 0.70 and show raw_text for CSV data
1dce94f
Raw
History Blame Contribute Delete
11 kB
"""
KAAL — Agent Nodes
==========================
Individual processing nodes for the LangGraph state graph.
Each node performs a single responsibility in the agent pipeline.
"""
from __future__ import annotations
import json
import logging
from datetime import datetime
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
logger = logging.getLogger("chronos.agent.nodes")
# System prompt for the Chronos agent
CHRONOS_SYSTEM_PROMPT = """You are a KAAL Agent — an AI assistant with structured temporal long-term memory.
You have access to the Chronos temporal memory system, which stores events as Subject-Verb-Object (SVO) tuples with timestamps. This gives you the ability to:
1. **Recall past events** across any connected SaaS tool or data source
2. **Reason temporally** — understand what happened when, in what order, and how events relate across time
3. **Multi-hop reasoning** — connect events across different time periods and sources
4. **Record new observations** to build your memory over time
When answering questions:
- Always check your temporal memory for relevant context
- Cite specific events and timestamps when available
- If you store a new event, confirm what you recorded
- Be precise about temporal relationships (before, after, during, caused by)
You are the persistent spine that makes AI agents actually useful over time."""
async def retrieve_memory_node(state: dict) -> dict:
"""
Node: Retrieve relevant temporal memory before the agent responds.
Queries ChromaDB (semantic) filtered by owner_id for tenant isolation.
"""
from api.deps import get_memory_store, get_vector_store
messages = state.get("messages", [])
owner_id = state.get("owner_id", "")
# Get the last user message as the query
last_user_msg = ""
for msg in reversed(messages):
if isinstance(msg, HumanMessage):
last_user_msg = msg.content
break
if not last_user_msg:
return state
try:
vector = get_vector_store()
memory = get_memory_store()
# Semantic search — filtered by owner for privacy.
# Threshold 0.70 (cosine distance) allows matching CSV-ingested raw rows
# which have low structural similarity to natural language queries.
search_results = await vector.semantic_search(
query=last_user_msg,
n_results=30,
owner_id=owner_id or None, # Tenant isolation
similarity_threshold=0.70,
)
# Fetch full events
event_ids = [r["id"] for r in search_results]
events = await memory.get_events_by_ids(event_ids) if event_ids else []
# Build memory context.
# For CSV-ingested rows (subject="unknown"), use raw_text as the primary
# display so the LLM sees the actual data (e.g. "Node: 64 | Views: 94200").
if events:
memory_lines = ["[Chronos Temporal Memory Context]"]
for event in events:
ts = event.timestamp.strftime("%Y-%m-%d %H:%M")
if event.raw_text and event.subject == "unknown":
memory_lines.append(f" [{ts}] {event.raw_text}")
else:
memory_lines.append(
f" [{ts}] {event.subject} {event.verb} {event.object}"
+ (f" | {event.raw_text[:120]}" if event.raw_text else "")
)
memory_context = "\n".join(memory_lines)
else:
memory_context = "[Chronos Memory: No relevant past events found]"
state["memory_context"] = memory_context
state["events_retrieved"] = len(events)
except Exception as e:
logger.warning(f"Memory retrieval failed: {e}")
state["memory_context"] = "[Chronos Memory: Retrieval unavailable]"
state["events_retrieved"] = 0
return state
async def call_model_node(state: dict) -> dict:
"""
Node: Call the LLM with memory-augmented context.
Uses the Heavy Pipeline from the Mixture of Agents Router.
Memory context is injected into the system prompt by retrieve_memory_node,
so the model can reason about temporal events without tool calling.
"""
messages = state.get("messages", [])
memory_context = state.get("memory_context", "")
# Build augmented system message
system_content = CHRONOS_SYSTEM_PROMPT
if memory_context:
system_content += f"\n\n{memory_context}"
# Ensure system message is first
augmented = [SystemMessage(content=system_content)]
for msg in messages:
if not isinstance(msg, SystemMessage):
if isinstance(msg, AIMessage):
# Strip reasoning_content from prior assistant messages to prevent
# "property reasoning_content is unsupported" errors in multi-turn
clean_kwargs = dict(msg.additional_kwargs)
clean_kwargs.pop("reasoning_content", None)
clean_msg = AIMessage(
content=msg.content,
additional_kwargs=clean_kwargs,
id=msg.id,
tool_calls=msg.tool_calls,
invalid_tool_calls=msg.invalid_tool_calls,
)
augmented.append(clean_msg)
else:
augmented.append(msg)
# Get the LLM from the Mixture of Agents Router (Heavy Pipeline)
try:
from chronos_core.llm_router import get_heavy_pipeline
llm = get_heavy_pipeline()
# Bind tools so the agent can interact with SaaS connectors
from agent.tools import CHRONOS_TOOLS
# Tools to bind (we can exclude memory since it's injected, but let's give it all)
llm_with_tools = llm.bind_tools(CHRONOS_TOOLS)
response = await llm_with_tools.ainvoke(augmented)
state["messages"] = messages + [response]
except Exception as e:
logger.error(f"LLM call failed: {e}")
error_msg = AIMessage(
content=f"I apologize, but I encountered an error: {str(e)}. "
f"Please check your API key configuration (CEREBRAS_API_KEY / GROQ_API_KEY)."
)
state["messages"] = messages + [error_msg]
return state
async def execute_tools_node(state: dict) -> dict:
"""
Node: Execute tool calls from the LLM response.
Handles Chronos-specific tools (memory query, event ingest, connectors).
"""
from langchain_core.messages import ToolMessage
messages = state.get("messages", [])
if not messages:
return state
last_msg = messages[-1]
if not isinstance(last_msg, AIMessage) or not last_msg.tool_calls:
return state
tool_messages = []
for tool_call in last_msg.tool_calls:
tool_name = tool_call["name"]
tool_args = tool_call["args"]
tool_id = tool_call.get("id", "")
try:
result = await _execute_tool(tool_name, tool_args, state)
tool_messages.append(
ToolMessage(content=result, tool_call_id=tool_id)
)
except Exception as e:
logger.error(f"Tool execution failed: {tool_name}{e}")
tool_messages.append(
ToolMessage(
content=f"Tool error: {str(e)}",
tool_call_id=tool_id,
)
)
state["messages"] = messages + tool_messages
return state
async def _execute_tool(name: str, args: dict, state: dict) -> str:
"""Execute a specific Chronos tool and return the result."""
from api.deps import get_memory_store, get_vector_store
if name == "query_chronos_memory":
vector = get_vector_store()
memory = get_memory_store()
results = await vector.semantic_search(
query=args.get("query", ""),
n_results=int(args.get("max_results", 10)),
source_ids=state.get("source_ids"),
)
event_ids = [r["id"] for r in results]
events = await memory.get_events_by_ids(event_ids) if event_ids else []
return json.dumps([
{
"timestamp": e.timestamp.isoformat(),
"subject": e.subject,
"verb": e.verb,
"object": e.object,
"raw_text": e.raw_text[:200] if e.raw_text else "",
"confidence": e.confidence,
}
for e in events
], indent=2)
elif name == "ingest_chronos_event":
from chronos_core.models import EventRecord
memory = get_memory_store()
vector = get_vector_store()
source_ids = state.get("source_ids", ["agent"])
event = EventRecord(
source_id=source_ids[0] if source_ids else "agent",
subject=args.get("subject", "agent"),
verb=args.get("verb", "recorded"),
object=args.get("obj", args.get("object", "")),
raw_text=args.get("raw_text", ""),
timestamp=datetime.utcnow(),
)
await memory.insert_event(event)
await vector.add_event(event)
state["events_created"] = state.get("events_created", 0) + 1
return f"Event stored: {event.subject} {event.verb} {event.object} at {event.timestamp.isoformat()}"
elif name == "list_connected_tools":
memory = get_memory_store()
source_ids = state.get("source_ids", [])
connectors = await memory.get_connectors(
source_ids[0] if source_ids else None
)
return json.dumps([
{
"id": c.id,
"name": c.name,
"description": c.description,
"endpoints": len(c.endpoints),
}
for c in connectors
], indent=2)
elif name == "call_connected_tool":
# Execute HTTP call to connected SaaS
import httpx
memory = get_memory_store()
connector = await memory.get_connector(args.get("connector_id", ""))
if not connector:
return "Error: Connector not found"
url = connector.base_url.rstrip("/") + args.get("endpoint_path", "/")
method = args.get("method", "GET").upper()
headers = {}
if connector.auth_header:
headers[connector.auth_header] = "CONFIGURED" # Would need actual auth
async with httpx.AsyncClient(timeout=30) as client:
if method == "GET":
resp = await client.get(url, headers=headers)
elif method == "POST":
body = json.loads(args.get("body", "{}")) if args.get("body") else {}
resp = await client.post(url, headers=headers, json=body)
else:
return f"Unsupported method: {method}"
return f"Status: {resp.status_code}\n{resp.text[:500]}"
else:
return f"Unknown tool: {name}"