logger.py 9.6 KB

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