import osfrom pathlib import Pathfrom typing import Any, Dict, Optionalfrom dotenv import load_dotenv# Load .env from cbt foldercbt_env_path = Path(__file__).parent / ".env"load_dotenv(dotenv_path=cbt_env_path)from langchain_openai import ChatOpenAIfrom workflow import create_workflowfrom state import create_initial_statedef get_llm_client(api_key: Optional[str] = None) -> ChatOpenAI: api_key = api_key or os.getenv("OPENROUTER_API_KEY") if not api_key: raise ValueError("OPENROUTER_API_KEY not set") base_url = "https://openrouter.ai/api/v1" model_name = "openai/gpt-oss-120b" return ChatOpenAI( api_key=api_key, base_url=base_url, model=model_name, temperature=0.7, )class LangGraphWorkflowAdapter: def __init__(self): self.workflow = None self._initialized = False def _ensure_initialized(self): if not self._initialized: llm = get_llm_client() self.workflow = create_workflow(llm) self._initialized = True async def invoke( self, user_intent: str, thread_id: str, task_id: str, metadata: Optional[Dict[str, Any]] = None, ): self._ensure_initialized() # Use Cerina's create_initial_state helper to ensure all required fields are present input_state = create_initial_state( user_intent=user_intent, session_id=thread_id, max_iterations=3 ) # Update with our metadata input_state["metadata"] = metadata or {} input_state["metadata"]["task_id"] = task_id config = {"configurable": {"thread_id": thread_id}} # Invoke the workflow and get the final state try: # Use ainvoke directly - simpler and more reliable than manual astream merging final_state = await self.workflow.ainvoke(input_state, config) except Exception as e: import traceback print(f"DEBUG: astream error: {e}") print(f"DEBUG: Traceback: {traceback.format_exc()}") # Fall back to ainvoke if astream fails try: final_state = await self.workflow.ainvoke(input_state, config) except Exception as e2: print(f"DEBUG: ainvoke also failed: {e2}") raise # Convert to dict if it's a Pydantic model, with fallback try: if hasattr(final_state, "dict"): state_dict = final_state.dict() elif hasattr(final_state, "model_dump"): state_dict = final_state.model_dump() elif isinstance(final_state, dict): # Already a dict, use as-is state_dict = final_state else: # Try to convert to dict try: state_dict = dict(final_state) except: # If that fails, try vars() state_dict = ( vars(final_state) if hasattr(final_state, "__dict__") else {} ) # Remove node execution metadata that shouldn't be in the state node_names = { "init", "draft", "safety_review", "clinical_critique", "supervisor", "finalize", "__pregel_pull__", } clean_state = {k: v for k, v in state_dict.items() if k not in node_names} return clean_state if clean_state else state_dict except Exception as e: # If conversion fails, try to extract as much as we can import json try: # Try JSON serialization as last resort json_str = json.dumps(final_state, default=str) parsed = json.loads(json_str) # Remove node metadata node_names = { "init", "draft", "safety_review", "clinical_critique", "supervisor", "finalize", "__pregel_pull__", } return {k: v for k, v in parsed.items() if k not in node_names} except: # Return empty dict if all else fails print(f"WARNING: Could not convert final_state to dict: {e}") return {}
"""Deep State Management - The "Blackboard" PatternRich, structured state that agents share and collaborate on."""from typing import TypedDict, List, Dict, Any, Optional, Annotatedfrom datetime import datetimedef merge_dicts(a: Optional[Dict], b: Optional[Dict]) -> Dict: """Merge two dictionaries, with b taking precedence""" if a is None: return b or {} if b is None: return a or {} result = dict(a) result.update(b) return resultdef append_list(a: List, b: List) -> List: """Append two lists""" return (a or []) + (b or [])def last_value_str(a: Optional[str], b: Optional[str]) -> Optional[str]: """Return the second (latest) non-None value""" return b if b is not None else adef last_value_int(a: Optional[int], b: Optional[int]) -> Optional[int]: """Return the second (latest) non-None value""" return b if b is not None else adef last_value_float(a: Optional[float], b: Optional[float]) -> Optional[float]: """Return the second (latest) non-None value""" return b if b is not None else aclass AgentNote(TypedDict): """Notes agents leave for each other on the blackboard""" agent_name: str note: str target_agent: Optional[str] # If None, note is for all agents timestamp: datetime priority: str # "info", "warning", "critical"class ProtocolDraft(TypedDict): """A versioned draft of the CBT exercise""" content: str version: int iteration: int created_by: str timestamp: datetime safety_score: Optional[float] empathy_score: Optional[float] clinical_score: Optional[float] feedback: List[str] # Feedback from other agentsclass ProtocolState(TypedDict): """ The shared state (blackboard) that all agents read from and write to. This is the central workspace for the multi-agent collaboration. """ # Core workflow state user_intent: str session_id: str status: Annotated[ str, last_value_str ] # "initializing", "drafting", "reviewing", "awaiting_approval", "approved", "completed" # Draft management current_draft: Annotated[ Optional[str], last_value_str ] # The latest draft content - takes last write draft_history: Annotated[List[ProtocolDraft], append_list] # All previous drafts current_version: Annotated[int, last_value_int] iteration_count: Annotated[int, last_value_int] max_iterations: int # Safety limit final_response: Annotated[Optional[str], last_value_str] # Final output for user # Agent collaboration agent_notes: Annotated[List[AgentNote], append_list] # The blackboard scratchpad active_agent: Annotated[ Optional[str], last_value_str ] # Which agent is currently working agent_decisions: Annotated[ Dict[str, Any], merge_dicts ] # Decisions made by each agent # Safety and quality metrics safety_checks: Annotated[ Dict[str, bool], merge_dicts ] # Results from safety guardian safety_score: Annotated[Optional[float], last_value_float] empathy_score: Annotated[Optional[float], last_value_float] clinical_score: Annotated[Optional[float], last_value_float] # Human-in-the-loop halted: Annotated[ bool, last_value_int ] # Whether workflow is paused for human review human_approved: Annotated[bool, last_value_int] human_edits: Annotated[Optional[str], last_value_str] # Edits made by human # Metadata start_time: datetime last_update: ( datetime # Not annotated - updated by nodes managing their own timestamps ) metadata: Annotated[Dict[str, Any], merge_dicts] # Additional contextdef create_initial_state( user_intent: str, session_id: str, max_iterations: int = 5) -> ProtocolState: """Create initial state for a new protocol generation""" now = datetime.now() return ProtocolState( user_intent=user_intent, session_id=session_id, status="initializing", current_draft=None, draft_history=[], current_version=0, iteration_count=0, max_iterations=max_iterations, final_response=None, agent_notes=[], active_agent=None, agent_decisions={}, safety_checks={}, safety_score=None, empathy_score=None, clinical_score=None, halted=False, human_approved=False, human_edits=None, start_time=now, last_update=now, metadata={}, )def add_agent_note( state: ProtocolState, agent_name: str, note: str, target_agent: Optional[str] = None, priority: str = "info",) -> ProtocolState: """Helper to add a note to the blackboard""" new_note = AgentNote( agent_name=agent_name, note=note, target_agent=target_agent, timestamp=datetime.now(), priority=priority, ) state["agent_notes"].append(new_note) state["last_update"] = datetime.now() return statedef add_draft_version( state: ProtocolState, content: str, created_by: str, safety_score: Optional[float] = None, empathy_score: Optional[float] = None, clinical_score: Optional[float] = None,) -> ProtocolState: """Helper to add a new draft version to history""" new_draft = ProtocolDraft( content=content, version=state["current_version"] + 1, iteration=state["iteration_count"], created_by=created_by, timestamp=datetime.now(), safety_score=safety_score, empathy_score=empathy_score, clinical_score=clinical_score, feedback=[], ) state["draft_history"].append(new_draft) state["current_version"] = new_draft["version"] state["current_draft"] = content state["last_update"] = datetime.now() return state
"""Map Cerina ProtocolState to/from Bindu artifacts and messages."""from uuid import UUIDfrom typing import Any, Dict, Listfrom bindu.common.protocol.types import Artifact, Partdef extract_text_from_bindu_message(messages: List[Dict[str, Any]]) -> str: """Extract user input text from Bindu message format. Args: messages: List of Bindu message dictionaries Returns: User's text input """ if not messages: return "" last_message = messages[-1] print(f"\nDEBUG extract_text: Full message: {last_message}") print(f"DEBUG extract_text: Message keys: {list(last_message.keys())}") content_val = last_message.get("content") print(f"DEBUG extract_text: Type of 'content': {type(content_val)}") print(f"DEBUG extract_text: Value of 'content': {content_val}") # Try to extract text from different possible formats # Format 1: "content" field (string - most common) if "content" in last_message: content = last_message["content"] if isinstance(content, str): text = content.strip() print(f"DEBUG extract_text: Found in 'content' (string): {text[:80]}...") return text # If content is a dict, try to extract text from it elif isinstance(content, dict): if "text" in content: text = content["text"] if isinstance(text, str): text = text.strip() print( f"DEBUG extract_text: Found in 'content.text': {text[:80]}..." ) return text # Try body or message field in content dict for key in ["body", "message", "value"]: if key in content and isinstance(content[key], str): text = content[key].strip() print( f"DEBUG extract_text: Found in 'content.{key}': {text[:80]}..." ) return text # Format 2: "parts" field (alternative format) parts = last_message.get("parts", []) if parts and isinstance(parts, list): text_parts = [] for p in parts: if isinstance(p, dict): if p.get("kind") == "text": text_parts.append(p.get("text", "")) elif "text" in p: text_parts.append(p["text"]) text = " ".join(filter(None, text_parts)).strip() if text: print(f"DEBUG extract_text: Found in 'parts': {text[:80]}...") return text # Format 3: "message" field if "message" in last_message: text = last_message["message"] if isinstance(text, str): text = text.strip() print(f"DEBUG extract_text: Found in 'message' (string): {text[:80]}...") return text # Format 4: "text" field if "text" in last_message: text = last_message["text"] if isinstance(text, str): text = text.strip() print(f"DEBUG extract_text: Found in 'text': {text[:80]}...") return text print("DEBUG extract_text: No text found, returning empty string") return ""def build_langgraph_input( bindu_messages: List[Dict[str, Any]], context_id: UUID, task_id: UUID,) -> Dict[str, Any]: """Convert Bindu message to LangGraph workflow input. Args: bindu_messages: Bindu message history context_id: Bindu context ID (maps to LangGraph thread_id) task_id: Bindu task ID Returns: LangGraph input dictionary """ user_input = extract_text_from_bindu_message(bindu_messages) # Map Bindu context_id to LangGraph thread_id # Format: "bindu_{context_id}" allows multi-turn conversations thread_id = f"bindu_{context_id}" return { "user_intent": user_input, "thread_id": thread_id, "task_id": str(task_id), # Add any other metadata from Bindu messages "metadata": { "context_id": str(context_id), "task_id": str(task_id), }, }def protocol_state_to_bindu_artifact( final_state: Dict[str, Any], artifact_id: UUID) -> Artifact: # Convert ProtocolState → dict if needed if hasattr(final_state, "dict"): try: final_state = final_state.dict() except Exception as e: # If dict() fails, try model_dump or vars print(f"DEBUG: Failed to call .dict(): {e}") if hasattr(final_state, "model_dump"): try: final_state = final_state.model_dump() except Exception as e2: print(f"DEBUG: Failed to call .model_dump(): {e2}") final_state = ( vars(final_state) if hasattr(final_state, "__dict__") else {} ) else: final_state = ( vars(final_state) if hasattr(final_state, "__dict__") else {} ) # Debug: print available keys print( f"DEBUG: Available keys in final_state: {list(final_state.keys()) if isinstance(final_state, dict) else 'Not a dict'}" ) # Extract fields from the proper ProtocolState structure final_response = final_state.get("current_draft", "") draft_history = final_state.get("draft_history", []) agent_notes = final_state.get("agent_notes", []) safety_score = final_state.get("safety_score") empathy_score = final_state.get("empathy_score") clinical_score = final_state.get("clinical_score") safety_checks = final_state.get("safety_checks", {}) agent_decisions = final_state.get("agent_decisions", {}) trace = final_state.get("trace", []) parts: List[Part] = [ {"kind": "text", "text": final_response or "[No draft generated]"} ] # Include structured debugging metadata parts.append( { "kind": "data", "data": { "draft": final_response, "draft_history": draft_history, "safety_score": safety_score, "empathy_score": empathy_score, "clinical_score": clinical_score, "safety_checks": safety_checks, "agent_decisions": agent_decisions, "agent_notes": agent_notes, "trace": final_state.get("trace", []), }, } ) return { "artifact_id": artifact_id, "name": "cbt_exercise", "description": "CBT exercise generated via Cerina Protocol Foundry (LangGraph)", "parts": parts, "metadata": { "source": "cerina", "pipeline": "drafter → safety → critic → supervisor", "final_draft": final_response, "draft_history": draft_history, "safety_score": safety_score, "empathy_score": empathy_score, "clinical_score": clinical_score, "safety_checks": safety_checks, "agent_decisions": agent_decisions, "agent_notes": agent_notes, "trace": final_state.get("trace", []), }, }def bindu_message_from_artifact(artifact: Artifact) -> List[Dict[str, Any]]: text_parts = [ p.get("text", "") for p in artifact.get("parts", []) if p.get("kind") == "text" ] content = " ".join(text_parts).strip() return [ { "role": "assistant", "content": content, "artifact": artifact, # include entire artifact for UI or downstream agents } ]
# Clone the Bindu repositorygit clone https://github.com/GetBindu/Bindu# Navigate to frontend directorycd frontend# Install dependenciesnpm install# Start frontend development servernpm run dev