logger.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  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. @property
  75. def run_id(self) -> str | None:
  76. return self._run_id
  77. @property
  78. def agent_name(self) -> str | None:
  79. return self._agent_name
  80. @property
  81. def log_file(self) -> Path | None:
  82. return self._log_file
  83. @property
  84. def jsonl_file(self) -> Path | None:
  85. return self._jsonl_file
  86. def start_run(
  87. self,
  88. user_input: str,
  89. *,
  90. model: str,
  91. agent_name: str | None = None,
  92. ) -> str:
  93. """Start a new run log file. Returns the run id."""
  94. if not self.enabled:
  95. return ""
  96. self.logs_dir.mkdir(parents=True, exist_ok=True)
  97. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  98. short_id = uuid.uuid4().hex[:8]
  99. slug = _slugify_agent_name(agent_name)
  100. self._agent_name = agent_name
  101. if slug:
  102. self._run_id = f"{slug}_{timestamp}_{short_id}"
  103. else:
  104. self._run_id = f"{timestamp}_{short_id}"
  105. self._log_file = self.logs_dir / f"run_{self._run_id}.log"
  106. self._jsonl_file = self.logs_dir / f"run_{self._run_id}.jsonl"
  107. self._seq = 0
  108. self._logger = logging.getLogger(f"supply_agent.run.{self._run_id}")
  109. self._logger.setLevel(logging.DEBUG)
  110. self._logger.handlers.clear()
  111. self._logger.propagate = False
  112. formatter = _FullContentFormatter()
  113. file_handler = logging.FileHandler(self._log_file, encoding="utf-8")
  114. file_handler.setFormatter(formatter)
  115. self._logger.addHandler(file_handler)
  116. console_handler = logging.StreamHandler()
  117. console_handler.setFormatter(formatter)
  118. self._logger.addHandler(console_handler)
  119. self._jsonl_fh = open(self._jsonl_file, "w", encoding="utf-8")
  120. self._emit(
  121. "run_start",
  122. {
  123. "run_id": self._run_id,
  124. "agent_name": agent_name,
  125. "model": model,
  126. "user_input": user_input,
  127. "log_file": str(self._log_file),
  128. "jsonl_file": str(self._jsonl_file),
  129. },
  130. title="RUN START",
  131. )
  132. return self._run_id
  133. def log_llm_input(
  134. self,
  135. iteration: int,
  136. model: str,
  137. messages: list[Message],
  138. tools: list[ToolDefinition] | None,
  139. temperature: float,
  140. ) -> None:
  141. """Log the complete LLM request payload."""
  142. if not self.enabled or not self._logger:
  143. return
  144. payload = {
  145. "iteration": iteration,
  146. "model": model,
  147. "temperature": temperature,
  148. "messages": [m.to_api_dict() for m in messages],
  149. "tools": [t.to_api_dict() for t in tools] if tools else None,
  150. "message_count": len(messages),
  151. "tool_count": len(tools) if tools else 0,
  152. }
  153. self._emit(
  154. "llm_input",
  155. payload,
  156. iteration=iteration,
  157. title=f"LLM INPUT | step={self._seq + 1} | iteration={iteration}",
  158. )
  159. def log_llm_output(
  160. self,
  161. iteration: int,
  162. response: Message,
  163. raw_response: Any | None = None,
  164. ) -> None:
  165. """Log the complete LLM response, highlighting reasoning and tool calls."""
  166. if not self.enabled or not self._logger:
  167. return
  168. parsed = response.model_dump()
  169. tool_calls = parsed.get("tool_calls") or []
  170. usage = _extract_usage(raw_response)
  171. payload: dict[str, Any] = {
  172. "iteration": iteration,
  173. "content": parsed.get("content"),
  174. "reasoning": parsed.get("reasoning"),
  175. "tool_calls": tool_calls,
  176. "has_reasoning": bool(parsed.get("reasoning")),
  177. "tool_call_count": len(tool_calls),
  178. "usage": usage,
  179. "parsed": parsed,
  180. }
  181. if raw_response is not None:
  182. if hasattr(raw_response, "model_dump"):
  183. payload["raw"] = raw_response.model_dump()
  184. else:
  185. payload["raw"] = raw_response
  186. self._emit(
  187. "llm_output",
  188. payload,
  189. iteration=iteration,
  190. title=f"LLM OUTPUT | step={self._seq + 1} | iteration={iteration}",
  191. )
  192. def log_tool_call(
  193. self,
  194. iteration: int,
  195. name: str,
  196. arguments: str,
  197. result: str,
  198. is_error: bool = False,
  199. *,
  200. tool_call_id: str | None = None,
  201. ) -> None:
  202. """Log a tool execution with full arguments and result."""
  203. if not self.enabled or not self._logger:
  204. return
  205. args_parsed = _try_parse_json(arguments)
  206. result_parsed = _try_parse_json(result)
  207. payload = {
  208. "iteration": iteration,
  209. "tool": name,
  210. "tool_call_id": tool_call_id,
  211. "arguments": arguments,
  212. "arguments_parsed": args_parsed,
  213. "result": result,
  214. "result_parsed": result_parsed,
  215. "is_error": is_error,
  216. }
  217. status = "ERROR" if is_error else "OK"
  218. self._emit(
  219. "tool_call",
  220. payload,
  221. iteration=iteration,
  222. title=f"TOOL CALL | step={self._seq + 1} | iteration={iteration} | {name} [{status}]",
  223. )
  224. def log_skill_loaded(self, iteration: int, skill_name: str) -> None:
  225. """Log when a skill is loaded."""
  226. if not self.enabled or not self._logger:
  227. return
  228. name = _extract_skill_name(skill_name)
  229. self._emit(
  230. "skill_loaded",
  231. {"iteration": iteration, "skill": name},
  232. iteration=iteration,
  233. title=f"SKILL LOADED | step={self._seq + 1} | iteration={iteration} | {name}",
  234. )
  235. def end_run(self, result: AgentResult) -> None:
  236. """Log run summary and close the run log."""
  237. if not self.enabled or not self._logger:
  238. return
  239. self._emit(
  240. "run_end",
  241. {
  242. "run_id": self._run_id,
  243. "agent_name": self._agent_name,
  244. "iterations": result.iterations,
  245. "tool_calls_made": result.tool_calls_made,
  246. "skills_used": result.skills_used,
  247. "final_content": result.content,
  248. },
  249. title="RUN END",
  250. )
  251. for handler in self._logger.handlers:
  252. handler.close()
  253. self._logger.handlers.clear()
  254. if self._jsonl_fh is not None:
  255. self._jsonl_fh.close()
  256. self._jsonl_fh = None
  257. def _emit(
  258. self,
  259. event: str,
  260. data: dict[str, Any],
  261. *,
  262. title: str,
  263. iteration: int | None = None,
  264. ) -> None:
  265. assert self._logger is not None
  266. self._seq += 1
  267. ts = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
  268. record: dict[str, Any] = {
  269. "event": event,
  270. "ts": ts,
  271. "seq": self._seq,
  272. "run_id": self._run_id,
  273. "data": data,
  274. }
  275. if iteration is not None:
  276. record["iteration"] = iteration
  277. if self._jsonl_fh is not None:
  278. self._jsonl_fh.write(json.dumps(record, ensure_ascii=False, default=str) + "\n")
  279. self._jsonl_fh.flush()
  280. separator = "=" * 80
  281. body = _serialize(data)
  282. self._logger.info("%s\n%s\n%s\n%s", separator, title, separator, body)
  283. # Module-level default logger instance
  284. _default_logger: AgentLogger | None = None
  285. def get_agent_logger(
  286. logs_dir: Path | str = "logs",
  287. *,
  288. enabled: bool = True,
  289. ) -> AgentLogger:
  290. """Get or create the default AgentLogger instance."""
  291. global _default_logger
  292. if _default_logger is None:
  293. _default_logger = AgentLogger(logs_dir, enabled=enabled)
  294. return _default_logger