import sys import uuid from pathlib import Path from dotenv import load_dotenv from rich.console import Console from rich.markdown import Markdown from rich.panel import Panel from rich.live import Live from rich.spinner import Spinner from rich.theme import Theme from prompt_toolkit import PromptSession from prompt_toolkit.styles import Style from lilith_agent.config import Config from lilith_agent.app import build_react_agent from lilith_agent.observability import setup_logging, setup_arize, JsonlTraceCallback env_path = Path(__file__).resolve().parent.parent.parent / ".env" if not env_path.exists(): env_path = Path.cwd() / ".env" load_dotenv(dotenv_path=env_path, override=True) custom_theme = Theme({ "info": "dim cyan", "warning": "magenta", "danger": "bold red", "lilith_primary": "italic magenta" }) console = Console(theme=custom_theme) prompt_style = Style.from_dict({ 'prompt': 'ansimagenta bold', }) LILITH_LOGO = r""" [magenta]██╗ ██╗██╗ ██╗████████╗██╗ ██╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗[/magenta] [magenta]██║ ██║██║ ██║╚══██╔══╝██║ ██║ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝[/magenta] [bright_magenta]██║ ██║██║ ██║ ██║ ███████║ ███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║ [/bright_magenta] [bright_magenta]██║ ██║██║ ██║ ██║ ██╔══██║ ██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ [/bright_magenta] [magenta]███████╗██║███████╗██║ ██║ ██║ ██║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ [/magenta] [magenta]╚══════╝╚═╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ [/magenta] [cyan italic]🦋 ReAct Research Assistant 🦋[/cyan italic] """ def print_logo(): console.print(LILITH_LOGO) def _extract_text(content) -> str: """Flatten AIMessage.content to a string. Anthropic returns a list of content blocks (e.g. [{"type": "text", "text": "..."}, {"type": "tool_use", ...}]); other providers return a plain string.""" if isinstance(content, str): return content if isinstance(content, list): parts = [] for block in content: if isinstance(block, dict): if block.get("type") == "text" and "text" in block: parts.append(block["text"]) elif isinstance(block, str): parts.append(block) return "\n".join(parts) return str(content) if content is not None else "" _CAVEMAN_HINTS = { "brief": "make model talk very brief", "full": "make model talk like caveman (terse, substance only)", "ultra": "make model talk ultra-compressed", } def _print_caveman_status(cfg): if cfg.caveman: hint = _CAVEMAN_HINTS.get(cfg.caveman_mode, "terse mode") console.print(f"[italic magenta]caveman: on[/italic magenta] [dim](mode: {cfg.caveman_mode} — {hint})[/dim]") else: console.print("[dim]caveman: off[/dim]") def main_loop(cfg): print_logo() _print_caveman_status(cfg) log_path = setup_logging(".lilith") console.print(f"[dim cyan]Logging to {log_path}[/dim cyan]") if setup_arize(project_name="lilith"): console.print("[dim cyan]Arize tracing: enabled[/dim cyan]") console.print("\n[dim cyan]Initializing agent...[/dim cyan]") try: graph = build_react_agent(cfg) except Exception as e: console.print(f"[bold red]Failed to build graph: {e}[/bold red]") sys.exit(1) console.print("[dim cyan]Agent ready. Type 'exit' or 'quit' to close.[/dim cyan]\n") session = PromptSession(style=prompt_style) # Persistent thread ID plus JSONL trace of every tool/LLM event for this session. thread_id = str(uuid.uuid4()) trace_path = log_path.with_name(log_path.stem + ".jsonl") trace_cb = JsonlTraceCallback(trace_path) thread_config = { "configurable": {"thread_id": thread_id}, "callbacks": [trace_cb], } console.print(f"[dim cyan]Trace: {trace_path}[/dim cyan]\n") while True: try: user_input = session.prompt("lilith 🦋 > ") except KeyboardInterrupt: continue except EOFError: break text = user_input.strip() if not text: continue if text.lower() in ("exit", "quit"): console.print("[magenta]Goodbye! 🦋[/magenta]") break if text.lower().startswith("/memory"): from lilith_agent.memory import _store, extract_and_compress_facts from lilith_agent.models import get_cheap_model parts = text.split(maxsplit=1) sub = parts[1].strip() if len(parts) > 1 else "list" if sub == "list": facts = _store.get_all_memories() episodes = _store.get_recent_episodes(limit=5) if facts: console.print("\n[bold cyan]── Semantic Facts ──[/bold cyan]") for m in facts: console.print(f" [dim]{m['id'][:8]}[/dim] {m['content']}") else: console.print("[dim]No semantic facts stored.[/dim]") if episodes: console.print("\n[bold cyan]── Episodic Memory ──[/bold cyan]") for e in episodes: console.print(f" [dim]{e['id'][:8]}[/dim] [bold]{e['task'][:60]}[/bold]\n {e['summary'][:120]}...") else: console.print("[dim]No episodes stored.[/dim]") console.print() elif sub.startswith("forget "): target_id = sub[len("forget "):].strip() deleted = _store.delete_memory_prefix(target_id) if deleted: console.print(f"[dim cyan]Deleted {deleted} fact(s) matching '{target_id}'.[/dim cyan]\n") else: console.print(f"[yellow]No fact found with id starting with '{target_id}'.[/yellow]\n") elif sub == "reflect": console.print("[dim cyan]Running memory reflection...[/dim cyan]") try: cheap_model = get_cheap_model(cfg) state = graph.get_state(thread_config) msgs = state.values.get("messages", []) if state and state.values else [] if msgs: extract_and_compress_facts(msgs, cheap_model) console.print("[dim cyan]Reflection complete.[/dim cyan]\n") else: console.print("[yellow]No messages in current thread to reflect on.[/yellow]\n") except Exception as exc: console.print(f"[bold red]Reflection failed: {exc}[/bold red]\n") else: console.print("[dim]Usage: /memory list | /memory forget | /memory reflect[/dim]\n") continue if text.lower() == "/clear": thread_id = str(uuid.uuid4()) trace_path = log_path.with_name(f"{log_path.stem}-{thread_id[:8]}.jsonl") trace_cb = JsonlTraceCallback(trace_path) thread_config = { "configurable": {"thread_id": thread_id}, "callbacks": [trace_cb], } console.print("[dim cyan]Conversation memory cleared. Starting a new thread.[/dim cyan]\n") continue if text.lower().startswith("/caveman") or text.lower().startswith("/cavemen"): parts = text.split() if len(parts) > 1: arg = parts[1].lower() if arg in ("off", "false", "no", "disable"): cfg.caveman = False elif arg in ("on", "true", "yes", "enable"): cfg.caveman = True else: cfg.caveman = True cfg.caveman_mode = arg else: cfg.caveman = not cfg.caveman state_str = "ENABLED" if cfg.caveman else "DISABLED" console.print(f"[dim cyan]CAVEMAN MODE {state_str} (mode: {cfg.caveman_mode})[/dim cyan]\n") continue # ReAct agent uses "messages" state natively input_state = {"messages": [("user", text)], "iterations": 0} console.print("\n") with Live(Spinner("dots", text="[magenta]Thinking...[/magenta]"), refresh_per_second=10) as live: last_message = None try: for chunk in graph.stream(input_state, thread_config, stream_mode="values"): if "messages" in chunk: # stream_mode="values" returns the full state after each node. # the last message added is the newest state. latest = chunk["messages"][-1] if latest.type == "ai" and latest.tool_calls: tool_strs = [] for tc in latest.tool_calls: name = tc.get("name", "unknown") dict_args = tc.get("args", {}) if isinstance(dict_args, dict): arg_str = ", ".join(f"{k}={repr(v)[:50] + '...' if len(repr(v)) > 50 else repr(v)}" for k, v in dict_args.items()) else: arg_str = repr(dict_args)[:50] + '...' if len(repr(dict_args)) > 50 else repr(dict_args) tool_strs.append(f"{name}({arg_str})") tools = " | ".join(tool_strs) live.console.print(f"[dim cyan] [TOOL][/dim cyan] {tools}") elif latest.type == "tool": content_str = str(latest.content).replace('\n', ' ') if len(content_str) > 300: content_preview = content_str[:150] + " ... " + content_str[-150:] else: content_preview = content_str live.console.print(f"[dim cyan] [OBSERVATION][/dim cyan] {latest.name}: {content_preview}") last_message = latest except Exception as e: live.console.print(f"[bold red]Agent Error: {e}[/bold red]") import traceback traceback.print_exc() continue # Final output if last_message and last_message.type == "ai": answer = _extract_text(last_message.content) if answer: console.print(Panel(Markdown(answer), title="🦋 [magenta]Lilith's Answer[/magenta]", border_style="magenta")) else: console.print("[yellow]Agent finished but returned no text content.[/yellow]\n") else: console.print("[yellow]Agent execution ended.[/yellow]\n") def main(): cfg = Config.from_env() main_loop(cfg) if __name__ == "__main__": main()