RemanenetSpy
Rebrand to Kaal — rename from Chronos OS, add Logo component with serif seal design, swap all Hexagon icons for Logo
4303a4c
Raw
History Blame Contribute Delete
7.42 kB
"""
KAAL — Agent State Graph
================================
LangGraph state graph that orchestrates the Chronos agent.
Architecture: retrieve_memory → call_model → (tools loop) → END
"""
from __future__ import annotations
import logging
import uuid
from typing import Annotated, Any
from langchain_core.messages import AIMessage, HumanMessage, BaseMessage
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from typing_extensions import TypedDict
from .nodes import (
retrieve_memory_node,
call_model_node,
execute_tools_node,
)
logger = logging.getLogger("chronos.agent.graph")
# ---------------------------------------------------------------------------
# Agent State
# ---------------------------------------------------------------------------
class ChronosAgentState(TypedDict):
"""State shared across all nodes in the agent graph."""
messages: Annotated[list[BaseMessage], add_messages]
memory_context: str
source_ids: list[str]
owner_id: str # Tenant isolation: only see own data
tool_ids: list[str]
events_retrieved: int
events_created: int
step_count: int
max_steps: int
# ---------------------------------------------------------------------------
# Routing Logic
# ---------------------------------------------------------------------------
def should_continue(state: ChronosAgentState) -> str:
"""
Conditional edge: decide whether to execute tools or finish.
Returns 'tools' if the last message has tool calls, else 'end'.
"""
messages = state.get("messages", [])
step_count = state.get("step_count", 0)
max_steps = state.get("max_steps", 10)
# Safety: stop if we've exceeded max steps
if step_count >= max_steps:
logger.warning(f"Agent hit max steps ({max_steps}), stopping")
return "end"
if not messages:
return "end"
last_msg = messages[-1]
if isinstance(last_msg, AIMessage) and last_msg.tool_calls:
return "tools"
return "end"
async def increment_step(state: ChronosAgentState) -> ChronosAgentState:
"""Increment the step counter after tool execution."""
state["step_count"] = state.get("step_count", 0) + 1
return state
# ---------------------------------------------------------------------------
# Graph Construction
# ---------------------------------------------------------------------------
def build_agent_graph() -> StateGraph:
"""
Build the Chronos agent state graph.
Flow:
START → retrieve_memory → call_model → [tools → call_model]* → END
"""
workflow = StateGraph(ChronosAgentState)
# Add nodes
workflow.add_node("retrieve_memory", retrieve_memory_node)
workflow.add_node("call_model", call_model_node)
workflow.add_node("tools", execute_tools_node)
workflow.add_node("increment", increment_step)
# Add edges
workflow.add_edge(START, "retrieve_memory")
workflow.add_edge("retrieve_memory", "call_model")
# Conditional: after model, either use tools or finish
workflow.add_conditional_edges(
"call_model",
should_continue,
{
"tools": "tools",
"end": END,
},
)
# After tools, increment step count then go back to model
workflow.add_edge("tools", "increment")
workflow.add_edge("increment", "call_model")
return workflow
# Compiled graph (singleton)
_graph = None
def get_agent_graph():
"""Get or create the compiled agent graph."""
global _graph
if _graph is None:
workflow = build_agent_graph()
_graph = workflow.compile()
logger.info("Chronos agent graph compiled successfully")
return _graph
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def run_agent_graph(
prompt: str,
thread_id: str | None = None,
source_ids: list[str] | None = None,
tool_ids: list[str] | None = None,
max_steps: int = 10,
owner_id: str = "",
) -> dict[str, Any]:
"""
Run the Chronos agent with temporal memory.
Args:
prompt: The user's task/question
thread_id: Session ID for continuity (None = new session)
source_ids: Memory scopes to search
tool_ids: Additional connector tool IDs to enable
max_steps: Maximum tool-use iterations
owner_id: API key owner for tenant-isolated memory access
Returns:
dict with: response, steps, events_retrieved, events_created
"""
graph = get_agent_graph()
tid = thread_id or uuid.uuid4().hex
initial_state: ChronosAgentState = {
"messages": [HumanMessage(content=prompt)],
"memory_context": "",
"source_ids": source_ids or [],
"owner_id": owner_id,
"tool_ids": tool_ids or [],
"events_retrieved": 0,
"events_created": 0,
"step_count": 0,
"max_steps": max_steps,
}
config = {"configurable": {"thread_id": tid}}
try:
result = await graph.ainvoke(initial_state, config=config)
# Extract final response
messages = result.get("messages", [])
final_response = ""
for msg in reversed(messages):
if isinstance(msg, AIMessage) and msg.content and not msg.tool_calls:
if isinstance(msg.content, list):
text_parts = []
for block in msg.content:
if isinstance(block, dict):
# Handle GLM/DeepSeek reasoning block formats
if block.get("type") == "text" and "text" in block:
text_parts.append(block["text"])
# Some models put text directly in 'text' without type
elif "text" in block and not block.get("type") == "thinking":
text_parts.append(block["text"])
elif isinstance(block, str):
text_parts.append(block)
final_response = "\n".join(text_parts)
else:
final_response = str(msg.content)
break
# Build step log
steps = []
for i, msg in enumerate(messages):
if isinstance(msg, AIMessage):
content_str = str(msg.content) if msg.content else ""
step = {"type": "ai", "content": content_str[:200]}
if msg.tool_calls:
step["tool_calls"] = [
{"name": tc["name"], "args": tc["args"]}
for tc in msg.tool_calls
]
steps.append(step)
return {
"response": final_response,
"thread_id": tid,
"steps": steps,
"events_retrieved": result.get("events_retrieved", 0),
"events_created": result.get("events_created", 0),
}
except Exception as e:
logger.error(f"Agent graph execution failed: {e}")
return {
"response": f"Agent error: {str(e)}",
"thread_id": tid,
"steps": [{"type": "error", "message": str(e)}],
"events_retrieved": 0,
"events_created": 0,
}