yc1838
feat(agent): add open-ended research methodology
5f00812
Raw
History Blame Contribute Delete
11.6 kB
from __future__ import annotations
import json
import logging
import os
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from langchain_core.callbacks import BaseCallbackHandler
log = logging.getLogger(__name__)
def setup_arize(project_name: str = "lilith") -> bool:
"""Enable Arize AX tracing if ARIZE_SPACE_ID and ARIZE_API_KEY are set.
Uses OpenInference's LangChain instrumentor, which auto-captures every
LangChain / LangGraph run (LLM calls, tool calls, chains). Returns True
when tracing is active, False otherwise.
"""
space_id = os.getenv("ARIZE_SPACE_ID")
api_key = os.getenv("ARIZE_API_KEY")
if not (space_id and api_key):
log.info("Arize tracing skipped: ARIZE_SPACE_ID or ARIZE_API_KEY not set")
return False
try:
from arize.otel import register
from openinference.instrumentation.langchain import LangChainInstrumentor
except ImportError:
log.warning(
"Arize packages missing. Install: "
"pip install arize-otel openinference-instrumentation-langchain"
)
return False
# Cap OTLP export attempts so a bad key / network can't block shutdown.
os.environ.setdefault("OTEL_EXPORTER_OTLP_TIMEOUT", "2")
os.environ.setdefault("OTEL_BSP_EXPORT_TIMEOUT", "2000")
import contextlib
import io
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
tracer_provider = register(
space_id=space_id,
api_key=api_key,
project_name=project_name,
)
LangChainInstrumentor().instrument(tracer_provider=tracer_provider)
import atexit
def _shutdown():
try:
tracer_provider.force_flush(timeout_millis=2000)
except Exception:
pass
try:
tracer_provider.shutdown()
except Exception:
pass
atexit.register(_shutdown)
log.info("Arize tracing enabled for project=%s", project_name)
return True
def setup_logging(log_dir: str | Path = ".lilith") -> Path:
"""Configure root logger to write INFO+ to a session log file and WARNING+ to stderr."""
log_dir = Path(log_dir)
log_dir.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
log_path = log_dir / f"session-{stamp}.log"
root = logging.getLogger()
root.setLevel(logging.INFO)
fh = logging.FileHandler(log_path)
fh.setLevel(logging.INFO)
fh.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)s %(name)s: %(message)s"
))
sh = logging.StreamHandler()
sh.setLevel(logging.WARNING)
sh.setFormatter(logging.Formatter("%(levelname)s %(name)s: %(message)s"))
# Avoid duplicate handlers on re-entry
root.handlers = [h for h in root.handlers if not getattr(h, "_lilith", False)]
fh._lilith = True
sh._lilith = True
root.addHandler(fh)
root.addHandler(sh)
return log_path
# Keys stripped from every trace record — high-volume, low-signal noise
# from provider responses (Gemini reasoning traces, safety metadata, etc).
_NOISE_KEYS = frozenset({
"reasoning",
"signature",
"safety_ratings",
"safety_settings",
})
def _sanitize(obj: Any, depth: int = 0) -> Any:
if depth > 10:
return obj
if hasattr(obj, "content") and hasattr(obj, "additional_kwargs") and hasattr(obj, "type"):
return _msg_to_dict(obj)
if isinstance(obj, dict):
return {k: _sanitize(v, depth + 1) for k, v in obj.items() if k not in _NOISE_KEYS}
if isinstance(obj, list):
return [_sanitize(v, depth + 1) for v in obj]
if isinstance(obj, tuple):
return tuple(_sanitize(v, depth + 1) for v in obj)
return obj
def _coerce(obj: Any) -> Any:
try:
out = _sanitize(obj)
# Attempt to see if it is JSON-serializable after sanitization
json.dumps(out)
return out
except Exception:
# Fallback to repr if there's still un-serializable objects
try:
return repr(out)
except UnboundLocalError:
return repr(obj)
def _msg_to_dict(msg: Any) -> dict:
"""Convert any BaseMessage-like object to a JSON-safe dict with all payload fields."""
try:
mtype = getattr(msg, "type", None) or msg.__class__.__name__
out = {
"type": mtype,
"content": _sanitize(getattr(msg, "content", None), 1),
}
for attr in ("name", "tool_call_id", "tool_calls", "additional_kwargs", "response_metadata", "usage_metadata"):
val = getattr(msg, attr, None)
if val:
out[attr] = _sanitize(val, 1)
return out
except Exception:
return {"type": "unknown", "repr": repr(msg)}
def _generations_to_list(response: Any) -> Any:
gens = getattr(response, "generations", None)
if not gens:
return _coerce(response)
out = []
for batch in gens:
batch_out = []
for gen in batch:
msg = getattr(gen, "message", None)
if msg is not None:
batch_out.append(_msg_to_dict(msg))
else:
info = getattr(gen, "generation_info", None)
batch_out.append({"text": getattr(gen, "text", ""), "info": _coerce(info)})
out.append(batch_out)
usage = getattr(response, "llm_output", None)
return {"generations": out, "llm_output": _coerce(usage)}
class JsonlTraceCallback(BaseCallbackHandler):
"""Append every LLM / tool / chain event as a JSONL record AND mirror to the Python logger.
Captures full payloads — no truncation — so you can replay a session from disk.
Writes are flushed after every line for real-time tailing.
"""
def __init__(self, path: str | Path, logger_name: str = "lilith_agent.trace"):
self.path = Path(path)
self.path.parent.mkdir(parents=True, exist_ok=True)
self._starts: dict[Any, float] = {}
self._logger = logging.getLogger(logger_name)
# Keep a persistent handle + flush each write for real-time tailing.
self._fh = self.path.open("a", buffering=1)
def _emit(self, record: dict) -> None:
record.setdefault("ts", datetime.now(timezone.utc).isoformat())
record.pop("run_id", None)
line = json.dumps(record, default=repr)
self._fh.write(line + "\n")
self._fh.flush()
# Mirror at DEBUG: LangGraph emits nested chain_start/chain_end for every
# subgraph, which floods the console at INFO. Full payloads still live in
# the .jsonl file; errors stay visible because we log them at WARNING.
summary = f"{record['event']}"
for k in ("name", "model", "elapsed_s"):
if k in record:
summary += f" {k}={record[k]}"
if "error" in record:
self._logger.warning("%s error=%s", summary, record["error"])
else:
self._logger.debug(summary)
# --- chain (LangGraph node) boundaries ---
def on_chain_start(self, serialized, inputs, *, run_id=None, **kwargs):
self._starts[run_id] = time.monotonic()
self._emit({
"event": "chain_start",
"run_id": str(run_id),
"name": (serialized or {}).get("name"),
"inputs": _coerce(inputs),
"tags": _coerce(kwargs.get("tags")),
})
def on_chain_end(self, outputs, *, run_id=None, **kwargs):
elapsed = time.monotonic() - self._starts.pop(run_id, time.monotonic())
self._emit({
"event": "chain_end",
"run_id": str(run_id),
"elapsed_s": round(elapsed, 3),
"outputs": _coerce(outputs),
})
def on_chain_error(self, error, *, run_id=None, **kwargs):
elapsed = time.monotonic() - self._starts.pop(run_id, time.monotonic())
self._emit({
"event": "chain_error",
"run_id": str(run_id),
"elapsed_s": round(elapsed, 3),
"error": f"{type(error).__name__}: {error}",
})
# --- tool boundaries ---
def on_tool_start(self, serialized, input_str, *, run_id=None, **kwargs):
self._starts[run_id] = time.monotonic()
self._emit({
"event": "tool_start",
"run_id": str(run_id),
"name": (serialized or {}).get("name"),
"input": _coerce(input_str),
})
def on_tool_end(self, output, *, run_id=None, **kwargs):
elapsed = time.monotonic() - self._starts.pop(run_id, time.monotonic())
self._emit({
"event": "tool_end",
"run_id": str(run_id),
"elapsed_s": round(elapsed, 3),
"output": _coerce(output),
})
def on_tool_error(self, error, *, run_id=None, **kwargs):
elapsed = time.monotonic() - self._starts.pop(run_id, time.monotonic())
self._emit({
"event": "tool_error",
"run_id": str(run_id),
"elapsed_s": round(elapsed, 3),
"error": f"{type(error).__name__}: {error}",
})
# --- LLM / chat-model boundaries (full payload, both directions) ---
def on_chat_model_start(self, serialized, messages, *, run_id=None, **kwargs):
self._starts[run_id] = time.monotonic()
self._emit({
"event": "chat_model_start",
"run_id": str(run_id),
"model": (serialized or {}).get("name"),
"invocation_params": _coerce(kwargs.get("invocation_params")),
"messages": [[_msg_to_dict(m) for m in batch] for batch in (messages or [])],
})
def on_llm_start(self, serialized, prompts, *, run_id=None, **kwargs):
self._starts[run_id] = time.monotonic()
self._emit({
"event": "llm_start",
"run_id": str(run_id),
"model": (serialized or {}).get("name"),
"prompts": [_coerce(p) for p in (prompts or [])],
})
def on_llm_end(self, response, *, run_id=None, **kwargs):
elapsed = time.monotonic() - self._starts.pop(run_id, time.monotonic())
self._emit({
"event": "llm_end",
"run_id": str(run_id),
"elapsed_s": round(elapsed, 3),
"response": _generations_to_list(response),
})
def on_llm_error(self, error, *, run_id=None, **kwargs):
elapsed = time.monotonic() - self._starts.pop(run_id, time.monotonic())
self._emit({
"event": "llm_error",
"run_id": str(run_id),
"elapsed_s": round(elapsed, 3),
"error": f"{type(error).__name__}: {error}",
})
# --- agent actions (ReAct reasoning steps) ---
def on_agent_action(self, action, *, run_id=None, **kwargs):
self._emit({
"event": "agent_action",
"run_id": str(run_id),
"tool": getattr(action, "tool", None),
"tool_input": _coerce(getattr(action, "tool_input", None)),
"log": getattr(action, "log", None),
})
def on_agent_finish(self, finish, *, run_id=None, **kwargs):
self._emit({
"event": "agent_finish",
"run_id": str(run_id),
"return_values": _coerce(getattr(finish, "return_values", None)),
"log": getattr(finish, "log", None),
})
def close(self) -> None:
try:
self._fh.flush()
self._fh.close()
except Exception:
pass