logger.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. from __future__ import annotations
  2. import json
  3. import logging
  4. import re
  5. import uuid
  6. from datetime import datetime, timezone
  7. from pathlib import Path
  8. from typing import Any
  9. from supply_agent.types import AgentResult, Message, ToolDefinition
  10. def _serialize(obj: Any) -> str:
  11. """Serialize any object to a complete, non-truncated JSON string."""
  12. def default(o: Any) -> Any:
  13. if hasattr(o, "model_dump"):
  14. return o.model_dump()
  15. if hasattr(o, "to_api_dict"):
  16. return o.to_api_dict()
  17. return str(o)
  18. return json.dumps(obj, ensure_ascii=False, indent=2, default=default)
  19. def _try_parse_json(text: str) -> Any:
  20. """Best-effort JSON parse; return original string on failure."""
  21. try:
  22. return json.loads(text)
  23. except (json.JSONDecodeError, TypeError):
  24. return text
  25. def _extract_usage(raw_response: Any) -> dict[str, Any] | None:
  26. if raw_response is None:
  27. return None
  28. usage = getattr(raw_response, "usage", None)
  29. if usage is None and isinstance(raw_response, dict):
  30. usage = raw_response.get("usage")
  31. if usage is None:
  32. return None
  33. if hasattr(usage, "model_dump"):
  34. return usage.model_dump()
  35. if isinstance(usage, dict):
  36. return usage
  37. return {"raw": str(usage)}
  38. def _extract_skill_name(skill_name_or_args: str) -> str:
  39. parsed = _try_parse_json(skill_name_or_args)
  40. if isinstance(parsed, dict):
  41. return str(parsed.get("name") or skill_name_or_args)
  42. return skill_name_or_args
  43. def slugify_agent_name(name: str | None) -> str | None:
  44. """Make agent name safe for use in log filenames / OSS paths."""
  45. if not name:
  46. return None
  47. slug = re.sub(r"[^\w\-]+", "_", name.strip(), flags=re.UNICODE)
  48. slug = re.sub(r"_+", "_", slug).strip("_.")
  49. return slug[:64] or None
  50. # Backwards-compatible alias
  51. _slugify_agent_name = slugify_agent_name
  52. class _FullContentFormatter(logging.Formatter):
  53. """Formatter that never truncates message content."""
  54. def format(self, record: logging.LogRecord) -> str:
  55. record.message = record.getMessage()
  56. return f"[{self.formatTime(record, '%Y-%m-%d %H:%M:%S')}] {record.message}"
  57. class AgentLogger:
  58. """
  59. Encapsulated logger for agent runs.
  60. Each run writes:
  61. - ``logs/run_<id>.log`` — human-readable full dump
  62. - ``logs/run_<id>.jsonl`` — structured event stream for visualization
  63. """
  64. def __init__(self, logs_dir: Path | str = "logs", *, enabled: bool = True) -> None:
  65. self.logs_dir = Path(logs_dir)
  66. self.enabled = enabled
  67. self._run_id: str | None = None
  68. self._log_file: Path | None = None
  69. self._jsonl_file: Path | None = None
  70. self._logger: logging.Logger | None = None
  71. self._seq: int = 0
  72. self._jsonl_fh: Any | None = None
  73. self._agent_name: str | None = None
  74. self._last_model: str | None = None
  75. @property
  76. def run_id(self) -> str | None:
  77. return self._run_id
  78. @property
  79. def agent_name(self) -> str | None:
  80. return self._agent_name
  81. @property
  82. def last_model(self) -> str | None:
  83. return self._last_model
  84. @property
  85. def log_file(self) -> Path | None:
  86. return self._log_file
  87. @property
  88. def jsonl_file(self) -> Path | None:
  89. return self._jsonl_file
  90. def start_run(
  91. self,
  92. user_input: str,
  93. *,
  94. model: str,
  95. agent_name: str | None = None,
  96. ) -> str:
  97. """Start a new run log file. Returns the run id."""
  98. if not self.enabled:
  99. return ""
  100. self.logs_dir.mkdir(parents=True, exist_ok=True)
  101. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  102. short_id = uuid.uuid4().hex[:8]
  103. slug = _slugify_agent_name(agent_name)
  104. self._agent_name = agent_name
  105. self._last_model = model
  106. if slug:
  107. self._run_id = f"{slug}_{timestamp}_{short_id}"
  108. else:
  109. self._run_id = f"{timestamp}_{short_id}"
  110. self._log_file = self.logs_dir / f"run_{self._run_id}.log"
  111. self._jsonl_file = self.logs_dir / f"run_{self._run_id}.jsonl"
  112. self._seq = 0
  113. self._logger = logging.getLogger(f"supply_agent.run.{self._run_id}")
  114. self._logger.setLevel(logging.DEBUG)
  115. self._logger.handlers.clear()
  116. self._logger.propagate = False
  117. formatter = _FullContentFormatter()
  118. file_handler = logging.FileHandler(self._log_file, encoding="utf-8")
  119. file_handler.setFormatter(formatter)
  120. self._logger.addHandler(file_handler)
  121. console_handler = logging.StreamHandler()
  122. console_handler.setFormatter(formatter)
  123. self._logger.addHandler(console_handler)
  124. self._jsonl_fh = open(self._jsonl_file, "w", encoding="utf-8")
  125. self._emit(
  126. "run_start",
  127. {
  128. "run_id": self._run_id,
  129. "agent_name": agent_name,
  130. "model": model,
  131. "user_input": user_input,
  132. "log_file": str(self._log_file),
  133. "jsonl_file": str(self._jsonl_file),
  134. },
  135. title="RUN START",
  136. )
  137. return self._run_id
  138. def log_llm_input(
  139. self,
  140. iteration: int,
  141. model: str,
  142. messages: list[Message],
  143. tools: list[ToolDefinition] | None,
  144. temperature: float,
  145. ) -> None:
  146. """Log the complete LLM request payload."""
  147. if not self.enabled or not self._logger:
  148. return
  149. self._last_model = model
  150. payload = {
  151. "iteration": iteration,
  152. "model": model,
  153. "temperature": temperature,
  154. "messages": [m.to_api_dict() for m in messages],
  155. "tools": [t.to_api_dict() for t in tools] if tools else None,
  156. "message_count": len(messages),
  157. "tool_count": len(tools) if tools else 0,
  158. }
  159. self._emit(
  160. "llm_input",
  161. payload,
  162. iteration=iteration,
  163. title=f"LLM INPUT | step={self._seq + 1} | iteration={iteration}",
  164. )
  165. def log_llm_output(
  166. self,
  167. iteration: int,
  168. response: Message,
  169. raw_response: Any | None = None,
  170. *,
  171. model: str | None = None,
  172. provider: str = "openrouter",
  173. ) -> None:
  174. """Log the complete LLM response, highlighting reasoning and tool calls."""
  175. if not self.enabled or not self._logger:
  176. return
  177. parsed = response.model_dump()
  178. tool_calls = parsed.get("tool_calls") or []
  179. usage = _extract_usage(raw_response)
  180. resolved_model = model or self._extract_model(raw_response) or self._last_model
  181. if resolved_model:
  182. self._last_model = resolved_model
  183. payload: dict[str, Any] = {
  184. "iteration": iteration,
  185. "model": resolved_model,
  186. "content": parsed.get("content"),
  187. "reasoning": parsed.get("reasoning"),
  188. "tool_calls": tool_calls,
  189. "has_reasoning": bool(parsed.get("reasoning")),
  190. "tool_call_count": len(tool_calls),
  191. "usage": usage,
  192. "parsed": parsed,
  193. }
  194. if raw_response is not None:
  195. if hasattr(raw_response, "model_dump"):
  196. payload["raw"] = raw_response.model_dump()
  197. else:
  198. payload["raw"] = raw_response
  199. self._emit(
  200. "llm_output",
  201. payload,
  202. iteration=iteration,
  203. title=f"LLM OUTPUT | step={self._seq + 1} | iteration={iteration}",
  204. )
  205. self._record_usage(
  206. iteration=iteration,
  207. model=resolved_model,
  208. usage=usage,
  209. provider=provider,
  210. )
  211. def log_tool_call(
  212. self,
  213. iteration: int,
  214. name: str,
  215. arguments: str,
  216. result: str,
  217. is_error: bool = False,
  218. *,
  219. tool_call_id: str | None = None,
  220. ) -> None:
  221. """Log a tool execution with full arguments and result."""
  222. if not self.enabled or not self._logger:
  223. return
  224. args_parsed = _try_parse_json(arguments)
  225. result_parsed = _try_parse_json(result)
  226. payload = {
  227. "iteration": iteration,
  228. "tool": name,
  229. "tool_call_id": tool_call_id,
  230. "arguments": arguments,
  231. "arguments_parsed": args_parsed,
  232. "result": result,
  233. "result_parsed": result_parsed,
  234. "is_error": is_error,
  235. }
  236. status = "ERROR" if is_error else "OK"
  237. self._emit(
  238. "tool_call",
  239. payload,
  240. iteration=iteration,
  241. title=f"TOOL CALL | step={self._seq + 1} | iteration={iteration} | {name} [{status}]",
  242. )
  243. def log_skill_loaded(self, iteration: int, skill_name: str) -> None:
  244. """Log when a skill is loaded."""
  245. if not self.enabled or not self._logger:
  246. return
  247. name = _extract_skill_name(skill_name)
  248. self._emit(
  249. "skill_loaded",
  250. {"iteration": iteration, "skill": name},
  251. iteration=iteration,
  252. title=f"SKILL LOADED | step={self._seq + 1} | iteration={iteration} | {name}",
  253. )
  254. def end_run(self, result: AgentResult) -> None:
  255. """Log run summary and close the run log."""
  256. if not self.enabled or not self._logger:
  257. return
  258. self._emit(
  259. "run_end",
  260. {
  261. "run_id": self._run_id,
  262. "agent_name": self._agent_name,
  263. "iterations": result.iterations,
  264. "tool_calls_made": result.tool_calls_made,
  265. "skills_used": result.skills_used,
  266. "final_content": result.content,
  267. },
  268. title="RUN END",
  269. )
  270. for handler in self._logger.handlers:
  271. handler.close()
  272. self._logger.handlers.clear()
  273. if self._jsonl_fh is not None:
  274. self._jsonl_fh.close()
  275. self._jsonl_fh = None
  276. def _extract_model(self, raw_response: Any) -> str | None:
  277. if raw_response is None:
  278. return None
  279. model = getattr(raw_response, "model", None)
  280. if model is None and isinstance(raw_response, dict):
  281. model = raw_response.get("model")
  282. return str(model) if model else None
  283. def _record_usage(
  284. self,
  285. *,
  286. iteration: int,
  287. model: str | None,
  288. usage: dict[str, Any] | None,
  289. provider: str,
  290. ) -> None:
  291. if not usage:
  292. return
  293. from supply_agent.logging.usage import record_llm_usage
  294. record_llm_usage(
  295. {
  296. "run_id": self._run_id,
  297. "agent_name": self._agent_name,
  298. "model": model,
  299. "provider": provider,
  300. "iteration": iteration,
  301. "usage": usage,
  302. }
  303. )
  304. def _emit(
  305. self,
  306. event: str,
  307. data: dict[str, Any],
  308. *,
  309. title: str,
  310. iteration: int | None = None,
  311. ) -> None:
  312. assert self._logger is not None
  313. self._seq += 1
  314. ts = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
  315. record: dict[str, Any] = {
  316. "event": event,
  317. "ts": ts,
  318. "seq": self._seq,
  319. "run_id": self._run_id,
  320. "data": data,
  321. }
  322. if iteration is not None:
  323. record["iteration"] = iteration
  324. if self._jsonl_fh is not None:
  325. self._jsonl_fh.write(json.dumps(record, ensure_ascii=False, default=str) + "\n")
  326. self._jsonl_fh.flush()
  327. separator = "=" * 80
  328. body = _serialize(data)
  329. self._logger.info("%s\n%s\n%s\n%s", separator, title, separator, body)
  330. # Module-level default logger instance
  331. _default_logger: AgentLogger | None = None
  332. def get_agent_logger(
  333. logs_dir: Path | str = "logs",
  334. *,
  335. enabled: bool = True,
  336. ) -> AgentLogger:
  337. """Get or create the default AgentLogger instance."""
  338. global _default_logger
  339. if _default_logger is None:
  340. _default_logger = AgentLogger(logs_dir, enabled=enabled)
  341. return _default_logger