yc1838
fix DSML token leak and use no-thinking model for all non-tool-bound paths
43d613a
Raw
History Blame Contribute Delete
12 kB
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 <id> | /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()