stirrup 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,944 @@
1
+ """Rich logging for agent workflows with visual hierarchy."""
2
+
3
+ import html
4
+ import json
5
+ import logging
6
+ from abc import ABC, abstractmethod
7
+ from collections.abc import Callable
8
+ from typing import Any, Self, cast
9
+
10
+ from pydantic import BaseModel
11
+ from rich import box
12
+ from rich.console import Console, RenderableType
13
+ from rich.live import Live
14
+ from rich.logging import RichHandler
15
+ from rich.markdown import Markdown
16
+ from rich.padding import Padding
17
+ from rich.panel import Panel
18
+ from rich.rule import Rule
19
+ from rich.spinner import Spinner
20
+ from rich.syntax import Syntax
21
+ from rich.table import Table
22
+ from rich.text import Text
23
+ from rich.tree import Tree
24
+
25
+ from stirrup.core.models import AssistantMessage, ToolMessage, UserMessage, _aggregate_list, aggregate_metadata
26
+
27
+ __all__ = [
28
+ "AgentLogger",
29
+ "AgentLoggerBase",
30
+ ]
31
+
32
+ # Shared console instance
33
+ console = Console()
34
+
35
+ # Indentation spaces per sub-agent nesting level
36
+ SUBAGENT_INDENT_SPACES: int = 8
37
+
38
+
39
+ def _is_subagent_metadata(data: object) -> bool:
40
+ """Check if data represents sub-agent metadata.
41
+
42
+ Sub-agent metadata can be:
43
+ - A Pydantic SubAgentMetadata object with run_metadata attribute
44
+ - A dict where all values are dicts/objects (from aggregate_metadata flattening)
45
+ """
46
+ # Check for Pydantic SubAgentMetadata object
47
+ if hasattr(data, "run_metadata") and isinstance(data.run_metadata, dict):
48
+ return True
49
+ # Check for flattened dict of dicts (from aggregate_metadata)
50
+ if isinstance(data, dict) and data:
51
+ return all(isinstance(v, dict) or hasattr(v, "model_dump") for v in data.values())
52
+ return False
53
+
54
+
55
+ def _format_token_usage(data: object) -> str:
56
+ """Format token_usage (dict or TokenUsage object) as a human-readable string."""
57
+ if isinstance(data, dict):
58
+ # Dict representation
59
+ data_dict = cast(dict[str, Any], data)
60
+ input_tokens: int = data_dict.get("input", 0)
61
+ output_tokens: int = data_dict.get("output", 0)
62
+ reasoning_tokens: int = data_dict.get("reasoning", 0)
63
+ elif hasattr(data, "input") and hasattr(data, "output"):
64
+ # Pydantic TokenUsage object - use getattr for type safety
65
+ input_tokens = int(getattr(data, "input", 0))
66
+ output_tokens = int(getattr(data, "output", 0))
67
+ reasoning_tokens = int(getattr(data, "reasoning", 0))
68
+ else:
69
+ return str(data)
70
+ total = input_tokens + output_tokens + reasoning_tokens
71
+ return f"{total:,} tokens"
72
+
73
+
74
+ def _get_nested_tools(data: object) -> dict[str, object]:
75
+ """Extract nested tools dict from sub-agent metadata."""
76
+ if hasattr(data, "run_metadata"):
77
+ # Pydantic SubAgentMetadata - return its run_metadata
78
+ run_metadata = data.run_metadata
79
+ if isinstance(run_metadata, dict):
80
+ return cast(dict[str, object], run_metadata)
81
+ if isinstance(data, dict):
82
+ # Already a dict
83
+ return cast(dict[str, object], data)
84
+ return {}
85
+
86
+
87
+ def _add_tool_branch(
88
+ parent: Tree,
89
+ tool_name: str,
90
+ tool_data: object,
91
+ skip_fields: set[str],
92
+ ) -> None:
93
+ """Add a tool entry to the tree, handling nested sub-agent data recursively.
94
+
95
+ Args:
96
+ parent: The tree or branch to add to
97
+ tool_name: Name of the tool or sub-agent
98
+ tool_data: The tool's metadata (dict, Pydantic model, list, or scalar)
99
+ skip_fields: Fields to skip when displaying dict contents
100
+ """
101
+ # Special case: token_usage formatted as total tokens
102
+ if tool_name == "token_usage":
103
+ if isinstance(tool_data, list) and tool_data:
104
+ parent.add(f"[dim]token_usage:[/] {_format_token_usage(tool_data[0])}")
105
+ else:
106
+ parent.add(f"[dim]token_usage:[/] {_format_token_usage(tool_data)}")
107
+ return
108
+
109
+ # Case 1: List → aggregate using __add__, then recurse
110
+ if isinstance(tool_data, list) and tool_data:
111
+ aggregated = _aggregate_list(tool_data)
112
+ if aggregated is not None:
113
+ _add_tool_branch(parent, tool_name, aggregated, skip_fields)
114
+ return
115
+
116
+ # Case 2: SubAgentMetadata → recurse into run_metadata only
117
+ if _is_subagent_metadata(tool_data):
118
+ branch = parent.add(f"[magenta]{tool_name}[/]")
119
+ for nested_name, nested_data in sorted(_get_nested_tools(tool_data).items()):
120
+ _add_tool_branch(branch, nested_name, nested_data, skip_fields)
121
+ return
122
+
123
+ # Case 3: Leaf node - display fields as branches
124
+ # Convert to dict if Pydantic model
125
+ if hasattr(tool_data, "model_dump"):
126
+ data_dict = cast(Callable[[], dict[str, Any]], tool_data.model_dump)()
127
+ elif isinstance(tool_data, dict):
128
+ data_dict = cast(dict[str, Any], tool_data)
129
+ else:
130
+ # Scalar value - just show it inline
131
+ parent.add(f"[magenta]{tool_name}[/]: {tool_data}")
132
+ return
133
+
134
+ # Show num_uses inline with the tool name if present
135
+ num_uses = data_dict.get("num_uses")
136
+ if num_uses is not None:
137
+ branch = parent.add(f"[magenta]{tool_name}[/]: {num_uses} call(s)")
138
+ else:
139
+ branch = parent.add(f"[magenta]{tool_name}[/]")
140
+
141
+ for k, v in data_dict.items():
142
+ if k not in skip_fields and v is not None:
143
+ branch.add(f"[dim]{k}:[/] {v}")
144
+
145
+
146
+ class AgentLoggerBase(ABC):
147
+ """Abstract base class for agent loggers.
148
+
149
+ Defines the interface that Agent uses for logging. Implement this to create
150
+ custom loggers (e.g., for testing, file output, or monitoring services).
151
+
152
+ Properties are set by Agent after construction:
153
+ - name, model, max_turns, depth: Agent configuration
154
+ - finish_params, run_metadata, output_dir: Set before __exit__ for final stats
155
+ """
156
+
157
+ # Properties set by Agent after construction
158
+ name: str
159
+ model: str | None
160
+ max_turns: int | None
161
+ depth: int
162
+
163
+ # State updated during run (set before __exit__)
164
+ finish_params: BaseModel | None
165
+ run_metadata: dict[str, list[Any]] | None
166
+ output_dir: str | None
167
+
168
+ @abstractmethod
169
+ def __enter__(self) -> Self:
170
+ """Enter logging context. Called when agent session starts."""
171
+ ...
172
+
173
+ @abstractmethod
174
+ def __exit__(
175
+ self,
176
+ exc_type: type[BaseException] | None,
177
+ exc_val: BaseException | None,
178
+ exc_tb: object,
179
+ ) -> None:
180
+ """Exit logging context. Called when agent session ends."""
181
+ ...
182
+
183
+ @abstractmethod
184
+ def on_step(
185
+ self,
186
+ step: int,
187
+ tool_calls: int = 0,
188
+ input_tokens: int = 0,
189
+ output_tokens: int = 0,
190
+ ) -> None:
191
+ """Report step progress and stats during agent execution."""
192
+ ...
193
+
194
+ @abstractmethod
195
+ def assistant_message(
196
+ self,
197
+ turn: int,
198
+ max_turns: int,
199
+ assistant_message: AssistantMessage,
200
+ ) -> None:
201
+ """Log an assistant message."""
202
+ ...
203
+
204
+ @abstractmethod
205
+ def user_message(self, user_message: UserMessage) -> None:
206
+ """Log a user message."""
207
+ ...
208
+
209
+ @abstractmethod
210
+ def task_message(self, task: str | list[Any]) -> None:
211
+ """Log the initial task/prompt at the start of a run."""
212
+ ...
213
+
214
+ @abstractmethod
215
+ def tool_result(self, tool_message: ToolMessage) -> None:
216
+ """Log a tool execution result."""
217
+ ...
218
+
219
+ @abstractmethod
220
+ def context_summarization_start(self, pct_used: float, cutoff: float) -> None:
221
+ """Log that context summarization is starting."""
222
+ ...
223
+
224
+ @abstractmethod
225
+ def context_summarization_complete(self, summary: str, bridge: str) -> None:
226
+ """Log completed context summarization."""
227
+ ...
228
+
229
+ # Standard logging methods
230
+ @abstractmethod
231
+ def debug(self, message: str, *args: object) -> None:
232
+ """Log a debug message."""
233
+ ...
234
+
235
+ @abstractmethod
236
+ def info(self, message: str, *args: object) -> None:
237
+ """Log an info message."""
238
+ ...
239
+
240
+ @abstractmethod
241
+ def warning(self, message: str, *args: object) -> None:
242
+ """Log a warning message."""
243
+ ...
244
+
245
+ @abstractmethod
246
+ def error(self, message: str, *args: object) -> None:
247
+ """Log an error message."""
248
+ ...
249
+
250
+
251
+ class AgentLogger(AgentLoggerBase):
252
+ """Rich console logger for agent workflows.
253
+
254
+ Implements AgentLoggerBase with rich formatting, spinners, and visual hierarchy.
255
+ Each agent (including sub-agents) should have its own logger instance.
256
+
257
+ Usage:
258
+ from stirrup.clients.chat_completions_client import ChatCompletionsClient
259
+
260
+ # Agent creates logger internally by default
261
+ client = ChatCompletionsClient(model="gpt-4")
262
+ agent = Agent(client=client, name="assistant")
263
+
264
+ # Or pass a pre-configured logger
265
+ logger = AgentLogger(show_spinner=False)
266
+ agent = Agent(client=client, name="assistant", logger=logger)
267
+
268
+ # Agent sets these properties before calling __enter__:
269
+ # logger.name, logger.model, logger.max_turns, logger.depth
270
+
271
+ # Agent sets these before calling __exit__:
272
+ # logger.finish_params, logger.run_metadata, logger.output_dir
273
+ """
274
+
275
+ def __init__(
276
+ self,
277
+ *,
278
+ show_spinner: bool = True,
279
+ level: int = logging.INFO,
280
+ ) -> None:
281
+ """Initialize the agent logger.
282
+
283
+ Args:
284
+ show_spinner: Whether to show a spinner while agent runs (only for depth=0)
285
+ level: Logging level (default: INFO)
286
+ """
287
+ # Properties set by Agent before __enter__
288
+ self.name: str = "agent"
289
+ self.model: str | None = None
290
+ self.max_turns: int | None = None
291
+ self.depth: int = 0
292
+
293
+ # State set by Agent before __exit__
294
+ self.finish_params: BaseModel | None = None
295
+ self.run_metadata: dict[str, list[Any]] | None = None
296
+ self.output_dir: str | None = None
297
+
298
+ # Configuration
299
+ self._show_spinner = show_spinner
300
+ self._level = level
301
+
302
+ # Spinner state (only used when depth == 0 and show_spinner is True)
303
+ self._current_step = 0
304
+ self._tool_calls = 0
305
+ self._input_tokens = 0
306
+ self._output_tokens = 0
307
+ self._live: Live | None = None
308
+
309
+ # Configure rich logging on first logger creation
310
+ self._configure_logging()
311
+
312
+ def _configure_logging(self) -> None:
313
+ """Configure rich logging with agent-aware formatting."""
314
+ handler = RichHandler(
315
+ console=console,
316
+ show_time=True,
317
+ show_path=False,
318
+ rich_tracebacks=True,
319
+ markup=True,
320
+ show_level=False,
321
+ )
322
+ handler.setFormatter(logging.Formatter("%(message)s"))
323
+
324
+ root = logging.getLogger()
325
+ root.handlers.clear()
326
+ root.addHandler(handler)
327
+ root.setLevel(self._level)
328
+
329
+ # Silence noisy loggers
330
+ for name in [
331
+ "LiteLLM",
332
+ "httpx",
333
+ "httpcore",
334
+ "openai",
335
+ "utils",
336
+ "asyncio",
337
+ "filelock",
338
+ "fsspec",
339
+ "urllib3",
340
+ "markdown_it",
341
+ "docker",
342
+ "e2b",
343
+ "e2b.api",
344
+ "e2b.sandbox_async",
345
+ "e2b.sandbox_sync",
346
+ ]:
347
+ logging.getLogger(name).setLevel(logging.WARNING)
348
+
349
+ # These loggers need ERROR level to fully suppress verbose debug output
350
+ for name in [
351
+ "mcp",
352
+ "mcp.client",
353
+ "mcp.client.sse",
354
+ "mcp.client.streamable_http",
355
+ "httpx_sse",
356
+ "readability",
357
+ "readability.readability",
358
+ "trafilatura",
359
+ "trafilatura.core",
360
+ "trafilatura.readability_lxml",
361
+ "htmldate",
362
+ "courlan",
363
+ ]:
364
+ logging.getLogger(name).setLevel(logging.ERROR)
365
+
366
+ def _get_indent(self) -> str:
367
+ """Get indentation string based on current agent depth."""
368
+ return "│ " * self.depth
369
+
370
+ def _print_indented(self, renderable: RenderableType, indent: str | int) -> None:
371
+ """Print a renderable with indentation using Padding.
372
+
373
+ Args:
374
+ renderable: The Rich renderable to print (Panel, Tree, Table, Rule, etc.)
375
+ indent: Either a string prefix or number of spaces for left padding
376
+ """
377
+ if isinstance(indent, str):
378
+ # For string indents, use capture method
379
+ with console.capture() as capture:
380
+ console.print(renderable)
381
+ output = capture.get()
382
+ for line in output.rstrip("\n").split("\n"):
383
+ console.print(f"{indent}{line}")
384
+ else:
385
+ # For numeric indents, use Padding
386
+ console.print(Padding(renderable, (0, 0, 0, indent)))
387
+
388
+ def _make_spinner_text(self) -> Text:
389
+ """Create styled text for the spinner display."""
390
+ text = Text()
391
+
392
+ text.append("Running ", style="bold green")
393
+ text.append(self.name, style="bold green")
394
+
395
+ # Separator
396
+ text.append(" │ ", style="dim")
397
+
398
+ # Step count
399
+ if self.max_turns:
400
+ text.append(f"{self._current_step}/{self.max_turns}", style="cyan bold")
401
+ text.append(" steps", style="cyan")
402
+ else:
403
+ text.append(f"{self._current_step}", style="cyan bold")
404
+ text.append(" steps", style="cyan")
405
+
406
+ # Separator
407
+ text.append(" │ ", style="dim")
408
+
409
+ # Tool calls
410
+ text.append(f"{self._tool_calls}", style="magenta bold")
411
+ text.append(" tool calls", style="magenta")
412
+
413
+ # Separator
414
+ text.append(" │ ", style="dim")
415
+
416
+ # Input tokens
417
+ text.append(f"{self._input_tokens:,}", style="yellow bold")
418
+ text.append(" input tokens", style="yellow")
419
+
420
+ # Separator
421
+ text.append(" │ ", style="dim")
422
+
423
+ # Output tokens
424
+ text.append(f"{self._output_tokens:,}", style="blue bold")
425
+ text.append(" output tokens", style="blue")
426
+
427
+ return text
428
+
429
+ def _make_spinner(self) -> Spinner:
430
+ """Create spinner with current stats."""
431
+ return Spinner("aesthetic", text=self._make_spinner_text(), style="green")
432
+
433
+ # -------------------------------------------------------------------------
434
+ # Context Manager Methods (AgentLoggerBase implementation)
435
+ # -------------------------------------------------------------------------
436
+
437
+ def __enter__(self) -> Self:
438
+ """Enter logging context. Logs agent start and starts spinner if depth=0."""
439
+ # Log agent start (rule + system prompt display)
440
+ indent_spaces = self.depth * SUBAGENT_INDENT_SPACES
441
+
442
+ # Build title with optional model info
443
+ model_str = f" ({self.model})" if self.model else ""
444
+ if self.depth == 0:
445
+ title = f"▶ {self.name}{model_str}"
446
+ console.rule(f"[bold cyan]{title}[/]", style="cyan")
447
+ else:
448
+ title = f"▶ {self.name}: Level {self.depth}{model_str}"
449
+ rule = Rule(f"[bold cyan]{title}[/]", style="cyan")
450
+ self._print_indented(rule, indent_spaces)
451
+ console.print()
452
+
453
+ # Start spinner only for top-level agent
454
+ if self.depth == 0 and self._show_spinner:
455
+ self._live = Live(self._make_spinner(), console=console, refresh_per_second=10)
456
+ self._live.start()
457
+
458
+ return self
459
+
460
+ def __exit__(
461
+ self,
462
+ exc_type: type[BaseException] | None,
463
+ exc_val: BaseException | None,
464
+ exc_tb: object,
465
+ ) -> None:
466
+ """Exit logging context. Stops spinner and logs completion stats."""
467
+ # Stop spinner first
468
+ if self._live:
469
+ self._live.stop()
470
+ self._live = None
471
+
472
+ error = str(exc_val) if exc_type is not None else None
473
+ self._log_finish(error=error)
474
+
475
+ def _log_finish(self, error: str | None = None) -> None:
476
+ """Log agent completion with full statistics."""
477
+ console.print() # Add spacing before finish
478
+
479
+ # Determine status
480
+ if error:
481
+ status = f"[bold red]✗ {self.name} - Error[/]"
482
+ style = "red"
483
+ elif self.finish_params is None:
484
+ # Agent didn't call finish tool (e.g., ran out of turns)
485
+ status = f"[bold red]✗ {self.name} - Failed[/]"
486
+ style = "red"
487
+ else:
488
+ status = f"[bold green]✓ {self.name} - Complete[/]"
489
+ style = "green"
490
+
491
+ indent_spaces = self.depth * SUBAGENT_INDENT_SPACES
492
+ if self.depth == 0:
493
+ console.rule(status, style=style)
494
+ else:
495
+ rule = Rule(status, style=style)
496
+ self._print_indented(rule, indent_spaces)
497
+
498
+ # Display error if present
499
+ if error:
500
+ error_text = Text(f"Error: {error}", style="red")
501
+ if self.depth > 0:
502
+ self._print_indented(error_text, indent_spaces)
503
+ else:
504
+ console.print(error_text)
505
+ console.print()
506
+
507
+ # For subagents, only show the status rule (and error if present)
508
+ if self.depth > 0:
509
+ return
510
+
511
+ # Extract paths from finish_params for use in metadata section
512
+ paths = None
513
+ if self.finish_params:
514
+ params = self.finish_params.model_dump()
515
+ reason = params.get("reason", "")
516
+ paths = params.get("paths")
517
+
518
+ # Reason panel as markdown (full width)
519
+ reason_panel = Panel(
520
+ Markdown(reason) if reason else "[dim]No reason provided[/]",
521
+ title="[bold]Reason[/]",
522
+ title_align="left",
523
+ border_style="cyan",
524
+ expand=True,
525
+ )
526
+ console.print(reason_panel)
527
+ console.print()
528
+
529
+ # Display run metadata statistics and paths in 1:1:1 layout
530
+ if self.run_metadata or paths:
531
+ # Aggregate metadata to roll up sub-agent token usage into the total
532
+ if self.run_metadata:
533
+ aggregated = aggregate_metadata(self.run_metadata, return_json_serializable=False)
534
+ token_usage_list = aggregated.get("token_usage", [])
535
+ else:
536
+ token_usage_list = []
537
+ tool_keys = (
538
+ [k for k in self.run_metadata if k not in ("token_usage", "finish")] if self.run_metadata else []
539
+ )
540
+
541
+ # Build tool usage tree
542
+ tool_panel = None
543
+ if tool_keys and self.run_metadata:
544
+ tool_tree = Tree("🔧 [bold]Tools[/]", guide_style="dim")
545
+ skip_fields = {"num_uses"}
546
+ for tool_name in sorted(tool_keys):
547
+ _add_tool_branch(tool_tree, tool_name, self.run_metadata[tool_name], skip_fields)
548
+
549
+ tool_panel = Panel(
550
+ tool_tree,
551
+ title="[bold]Tool Usage[/]",
552
+ title_align="left",
553
+ border_style="magenta",
554
+ expand=True,
555
+ )
556
+
557
+ # Build paths panel
558
+ paths_panel = None
559
+ if paths:
560
+ paths_tree = Tree("📁 [bold]Files[/]", guide_style="dim")
561
+ # If output_dir is provided, add it as a parent node
562
+ if self.output_dir:
563
+ output_branch = paths_tree.add(f"[magenta]{self.output_dir}/[/]")
564
+ for path in paths:
565
+ output_branch.add(f"[green]{path}[/]")
566
+ else:
567
+ for path in paths:
568
+ paths_tree.add(f"[green]{path}[/]")
569
+
570
+ paths_panel = Panel(
571
+ paths_tree,
572
+ title="[bold]Paths[/]",
573
+ title_align="left",
574
+ border_style="cyan",
575
+ expand=True,
576
+ )
577
+
578
+ # Build token usage table
579
+ token_panel = None
580
+ if token_usage_list:
581
+ total_input = sum(getattr(u, "input", 0) for u in token_usage_list)
582
+ total_output = sum(getattr(u, "output", 0) for u in token_usage_list)
583
+ total_reasoning = sum(getattr(u, "reasoning", 0) for u in token_usage_list)
584
+ total_tokens = sum(getattr(u, "total", 0) for u in token_usage_list)
585
+
586
+ token_table = Table(
587
+ box=box.SIMPLE,
588
+ show_header=True,
589
+ header_style="bold",
590
+ show_footer=True,
591
+ expand=True,
592
+ )
593
+ token_table.add_column("Type", style="cyan", footer="[bold]Total[/]")
594
+ token_table.add_column("Count", justify="right", style="green", footer=f"[bold]{total_tokens:,}[/]")
595
+
596
+ token_table.add_row("Input", f"{total_input:,}")
597
+ token_table.add_row("Output", f"{total_output:,}")
598
+ if total_reasoning > 0:
599
+ token_table.add_row("Reasoning", f"{total_reasoning:,}")
600
+
601
+ token_panel = Panel(
602
+ token_table,
603
+ title="[bold]Token Usage[/]",
604
+ title_align="left",
605
+ border_style="green",
606
+ expand=True,
607
+ )
608
+
609
+ # Display panels in 1:1:1 ratio layout (Tool Usage | Paths | Token Usage)
610
+ panels = [p for p in [tool_panel, paths_panel, token_panel] if p is not None]
611
+ if panels:
612
+ layout_table = Table.grid(expand=True)
613
+ for _ in panels:
614
+ layout_table.add_column(ratio=1)
615
+ layout_table.add_row(*panels)
616
+ console.print(layout_table)
617
+ console.print()
618
+
619
+ console.rule(style="dim")
620
+
621
+ # Display max turns exceeded error panel (only for top-level agent, and only if no other error)
622
+ if self.finish_params is None and self.max_turns is not None and error is None:
623
+ content = Text()
624
+ content.append("Maximum turns reached\n\n", style="bold")
625
+ content.append("Turns used: ", style="dim")
626
+ content.append(f"{self.max_turns}", style="bold red")
627
+ content.append("\n\n")
628
+ content.append(
629
+ "The agent was not able to finish the task. Consider increasing the max_turns parameter.",
630
+ style="italic",
631
+ )
632
+
633
+ panel = Panel(
634
+ content,
635
+ title="[bold red]⚠ Max Turns Exceeded[/]",
636
+ title_align="left",
637
+ border_style="red",
638
+ padding=(0, 1),
639
+ )
640
+ console.print(panel)
641
+ console.print()
642
+
643
+ def on_step(
644
+ self,
645
+ step: int,
646
+ tool_calls: int = 0,
647
+ input_tokens: int = 0,
648
+ output_tokens: int = 0,
649
+ ) -> None:
650
+ """Report step progress and stats during agent execution."""
651
+ self._current_step = step
652
+ self._tool_calls = tool_calls
653
+ self._input_tokens = input_tokens
654
+ self._output_tokens = output_tokens
655
+ if self._live:
656
+ self._live.update(self._make_spinner())
657
+
658
+ def set_level(self, level: int) -> None:
659
+ """Set the logging level."""
660
+ self._level = level
661
+ # Also update root logger level
662
+ logging.getLogger().setLevel(level)
663
+
664
+ def is_enabled_for(self, level: int) -> bool:
665
+ """Check if a given log level is enabled."""
666
+ return level >= self._level
667
+
668
+ # -------------------------------------------------------------------------
669
+ # Standard logging methods (debug, info, warning, error, critical)
670
+ # -------------------------------------------------------------------------
671
+
672
+ def debug(self, message: str, *args: object) -> None:
673
+ """Log a debug message (dim style)."""
674
+ if self._level <= logging.DEBUG:
675
+ formatted = message % args if args else message
676
+ console.print(f"[dim]{formatted}[/]")
677
+
678
+ def info(self, message: str, *args: object) -> None:
679
+ """Log an info message."""
680
+ if self._level <= logging.INFO:
681
+ formatted = message % args if args else message
682
+ console.print(formatted)
683
+
684
+ def warning(self, message: str, *args: object) -> None:
685
+ """Log a warning message (yellow style)."""
686
+ if self._level <= logging.WARNING:
687
+ formatted = message % args if args else message
688
+ console.print(f"[yellow]⚠ {formatted}[/]")
689
+
690
+ def error(self, message: str, *args: object) -> None:
691
+ """Log an error message (red style)."""
692
+ if self._level <= logging.ERROR:
693
+ formatted = message % args if args else message
694
+ console.print(f"[red]✗ {formatted}[/]")
695
+
696
+ def critical(self, message: str, *args: object) -> None:
697
+ """Log a critical message (bold red style)."""
698
+ if self._level <= logging.CRITICAL:
699
+ formatted = message % args if args else message
700
+ console.print(f"[bold red]✗ CRITICAL: {formatted}[/]")
701
+
702
+ def exception(self, message: str, *args: object) -> None:
703
+ """Log an error message with exception traceback (red style with traceback)."""
704
+ if self._level <= logging.ERROR:
705
+ formatted = message % args if args else message
706
+ console.print(f"[red]✗ {formatted}[/]")
707
+ console.print_exception()
708
+
709
+ # -------------------------------------------------------------------------
710
+ # Message Logging Methods (AgentLoggerBase implementation)
711
+ # -------------------------------------------------------------------------
712
+
713
+ def assistant_message(
714
+ self,
715
+ turn: int,
716
+ max_turns: int,
717
+ assistant_message: AssistantMessage,
718
+ ) -> None:
719
+ """Log an assistant message with content and tool calls in a panel.
720
+
721
+ Args:
722
+ turn: Current turn number (1-indexed)
723
+ max_turns: Maximum number of turns
724
+ assistant_message: The assistant's response message
725
+ """
726
+ if self._level > logging.INFO:
727
+ return
728
+
729
+ # Build panel content
730
+ content = Text()
731
+
732
+ # Add assistant content if present
733
+ if assistant_message.content:
734
+ text = assistant_message.content
735
+ if isinstance(text, list):
736
+ text = "\n".join(str(block) for block in text)
737
+ # Truncate long content
738
+ if len(text) > 500:
739
+ text = text[:500] + "..."
740
+ content.append(text, style="white")
741
+
742
+ # Add tool calls if present
743
+ if assistant_message.tool_calls:
744
+ if assistant_message.content:
745
+ content.append("\n\n")
746
+ content.append("Tool Calls:\n", style="bold magenta")
747
+ for tc in assistant_message.tool_calls:
748
+ args_parsed = json.loads(tc.arguments)
749
+ args_formatted = json.dumps(args_parsed, indent=2, ensure_ascii=False)
750
+ args_preview = args_formatted[:1000] + "..." if len(args_formatted) > 1000 else args_formatted
751
+ content.append(f" 🔧 {tc.name}", style="magenta")
752
+ content.append(args_preview, style="dim")
753
+
754
+ # Create and print panel with agent name in title
755
+ title = f"[bold]AssistantMessage[/bold] │ {self.name} │ Turn {turn}/{max_turns}"
756
+ panel = Panel(content, title=title, title_align="left", border_style="yellow", padding=(0, 1))
757
+
758
+ if self.depth > 0:
759
+ self._print_indented(panel, self.depth * SUBAGENT_INDENT_SPACES)
760
+ else:
761
+ console.print(panel)
762
+
763
+ def user_message(self, user_message: UserMessage) -> None:
764
+ """Log a user message in a panel.
765
+
766
+ Args:
767
+ user_message: The user's message
768
+ """
769
+ if self._level > logging.INFO:
770
+ return
771
+
772
+ # Build panel content
773
+ content = Text()
774
+
775
+ # Add user content
776
+ if user_message.content:
777
+ text = user_message.content
778
+ if isinstance(text, list):
779
+ text = "\n".join(str(block) for block in text)
780
+ # Truncate long content
781
+ if len(text) > 500:
782
+ text = text[:500] + "..."
783
+ content.append(text, style="white")
784
+
785
+ # Create and print panel with agent name in title
786
+ title = f"[bold]UserMessage[/bold] │ {self.name}"
787
+ panel = Panel(content, title=title, title_align="left", border_style="blue", padding=(0, 1))
788
+
789
+ if self.depth > 0:
790
+ self._print_indented(panel, self.depth * SUBAGENT_INDENT_SPACES)
791
+ else:
792
+ console.print(panel)
793
+
794
+ def task_message(self, task: str | list[Any]) -> None:
795
+ """Log the initial task/prompt at the start of a run."""
796
+ if self._level > logging.INFO:
797
+ return
798
+
799
+ # Convert list content to string
800
+ if isinstance(task, list):
801
+ task = "\n".join(str(block) for block in task)
802
+
803
+ # Clean up whitespace from multi-line strings
804
+ # Normalize each line by stripping leading/trailing whitespace and rejoining
805
+ lines = [line.strip() for line in task.split("\n")]
806
+ task = " ".join(line for line in lines if line)
807
+
808
+ # Use "Sub Agent" prefix for nested agents
809
+ prefix = "Sub Agent" if self.depth > 0 else "Agent"
810
+
811
+ if self.depth > 0:
812
+ indent = " " * (self.depth * SUBAGENT_INDENT_SPACES)
813
+ console.print(f"{indent}[bold]{prefix} Task:[/bold]")
814
+ console.print()
815
+ for line in task.split("\n"):
816
+ console.print(f"{indent}{line}")
817
+ else:
818
+ console.print(f"[bold]{prefix} Task:[/bold]")
819
+ console.print()
820
+ console.print(task)
821
+
822
+ console.print() # Add gap after task section
823
+
824
+ def warnings_message(self, warnings: list[str]) -> None:
825
+ """Display warnings at run start as simple text."""
826
+ if self._level > logging.INFO or not warnings:
827
+ return
828
+
829
+ console.print("[bold orange1]Warnings[/bold orange1]")
830
+ console.print()
831
+ for warning in warnings:
832
+ console.print(f"[orange1]⚠ {warning}[/orange1]")
833
+ console.print() # Add gap between warnings
834
+
835
+ def tool_result(self, tool_message: ToolMessage) -> None:
836
+ """Log a single tool execution result in a panel with XML syntax highlighting.
837
+
838
+ Args:
839
+ tool_message: The tool execution result
840
+ """
841
+ if self._level > logging.INFO:
842
+ return
843
+
844
+ tool_name = tool_message.name or "unknown"
845
+
846
+ # Get result content
847
+ result_text = tool_message.content
848
+ if isinstance(result_text, list):
849
+ result_text = "\n".join(str(block) for block in result_text)
850
+
851
+ # Unescape HTML entities (e.g., &lt; -> <, &gt; -> >, &amp; -> &)
852
+ result_text = html.unescape(result_text)
853
+
854
+ # Truncate long results
855
+ if len(result_text) > 1000:
856
+ result_text = result_text[:1000] + "..."
857
+
858
+ # Format as XML with syntax highlighting
859
+ content = Syntax(result_text, "xml", theme="monokai", word_wrap=True)
860
+
861
+ # Status indicator in title with agent name
862
+ status = "✓" if tool_message.args_was_valid else "✗"
863
+ status_style = "green" if tool_message.args_was_valid else "red"
864
+ title = f"[{status_style}]{status}[/{status_style}] [bold]ToolResult[/bold] │ {self.name} │ [green]{tool_name}[/green]"
865
+
866
+ panel = Panel(content, title=title, title_align="left", border_style="green", padding=(0, 1))
867
+
868
+ if self.depth > 0:
869
+ self._print_indented(panel, self.depth * SUBAGENT_INDENT_SPACES)
870
+ else:
871
+ console.print(panel)
872
+
873
+ # -------------------------------------------------------------------------
874
+ # Context Summarization Methods (AgentLoggerBase implementation)
875
+ # -------------------------------------------------------------------------
876
+
877
+ def context_summarization_start(self, pct_used: float, cutoff: float) -> None:
878
+ """Log context window summarization starting in an orange panel.
879
+
880
+ Args:
881
+ pct_used: Percentage of context window currently used (0.0-1.0)
882
+ cutoff: The threshold that triggered summarization (0.0-1.0)
883
+ """
884
+ # Build panel content
885
+ content = Text()
886
+ content.append("Context window limit reached\n\n", style="bold")
887
+ content.append("Used: ", style="dim")
888
+ content.append(f"{pct_used:.1%}", style="bold orange1")
889
+ content.append(" │ ", style="dim")
890
+ content.append("Threshold: ", style="dim")
891
+ content.append(f"{cutoff:.1%}", style="bold")
892
+ content.append("\n\n", style="dim")
893
+ content.append("Summarizing conversation history...", style="italic")
894
+
895
+ panel = Panel(
896
+ content,
897
+ title="[bold orange1]📝 Context Summarization[/]",
898
+ title_align="left",
899
+ border_style="orange1",
900
+ padding=(0, 1),
901
+ )
902
+
903
+ if self.depth > 0:
904
+ self._print_indented(panel, self.depth * SUBAGENT_INDENT_SPACES)
905
+ else:
906
+ console.print(panel)
907
+
908
+ def context_summarization_complete(self, summary: str, bridge: str) -> None:
909
+ """Log the completed context summarization with summary content.
910
+
911
+ Args:
912
+ summary: The generated summary of the conversation
913
+ bridge: The bridge message that will be used to continue the conversation
914
+ """
915
+ # Truncate long summaries for display
916
+ summary_display = summary
917
+ if len(summary_display) > 800:
918
+ summary_display = summary_display[:800] + "..."
919
+
920
+ # Build panel content
921
+ content = Text()
922
+ content.append("Summary:\n", style="bold")
923
+ content.append(summary_display, style="white")
924
+
925
+ if self._level > logging.INFO:
926
+ bridge_display = bridge
927
+ if len(bridge_display) > 200:
928
+ bridge_display = bridge_display[:200] + "..."
929
+ content.append("\n\n")
930
+ content.append("Bridge Message:\n", style="bold dim")
931
+ content.append(bridge_display, style="dim italic")
932
+
933
+ panel = Panel(
934
+ content,
935
+ title="[bold green]✓ Summary Generated[/]",
936
+ title_align="left",
937
+ border_style="green",
938
+ padding=(0, 1),
939
+ )
940
+
941
+ if self.depth > 0:
942
+ self._print_indented(panel, self.depth * SUBAGENT_INDENT_SPACES)
943
+ else:
944
+ console.print(panel)