aixtools 0.2.14__tar.gz → 0.2.16__tar.gz

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.

Potentially problematic release.


This version of aixtools might be problematic. Click here for more details.

Files changed (100) hide show
  1. {aixtools-0.2.14 → aixtools-0.2.16}/PKG-INFO +35 -15
  2. {aixtools-0.2.14 → aixtools-0.2.16}/README.md +34 -14
  3. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/_version.py +3 -3
  4. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/agents/agent.py +4 -2
  5. aixtools-0.2.16/aixtools/agents/nodes_to_md.py +201 -0
  6. aixtools-0.2.16/aixtools/agents/nodes_to_message.py +30 -0
  7. aixtools-0.2.16/aixtools/agents/nodes_to_str.py +109 -0
  8. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/logging/log_objects.py +27 -15
  9. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/logging/model_patch_logging.py +6 -6
  10. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/mcp/client.py +2 -2
  11. aixtools-0.2.16/aixtools/testing/agent_mock.py +143 -0
  12. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/utils/utils.py +35 -3
  13. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools.egg-info/SOURCES.txt +4 -0
  14. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/.chainlit/config.toml +0 -0
  15. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/.chainlit/translations/bn.json +0 -0
  16. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/.chainlit/translations/en-US.json +0 -0
  17. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/.chainlit/translations/gu.json +0 -0
  18. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/.chainlit/translations/he-IL.json +0 -0
  19. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/.chainlit/translations/hi.json +0 -0
  20. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/.chainlit/translations/ja.json +0 -0
  21. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/.chainlit/translations/kn.json +0 -0
  22. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/.chainlit/translations/ml.json +0 -0
  23. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/.chainlit/translations/mr.json +0 -0
  24. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/.chainlit/translations/nl.json +0 -0
  25. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/.chainlit/translations/ta.json +0 -0
  26. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/.chainlit/translations/te.json +0 -0
  27. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/.chainlit/translations/zh-CN.json +0 -0
  28. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/__init__.py +0 -0
  29. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/a2a/app.py +0 -0
  30. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/a2a/google_sdk/__init__.py +0 -0
  31. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/a2a/google_sdk/pydantic_ai_adapter/agent_executor.py +0 -0
  32. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/a2a/google_sdk/pydantic_ai_adapter/storage.py +0 -0
  33. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/a2a/google_sdk/remote_agent_connection.py +0 -0
  34. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/a2a/google_sdk/utils.py +0 -0
  35. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/a2a/utils.py +0 -0
  36. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/agents/__init__.py +0 -0
  37. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/agents/agent_batch.py +0 -0
  38. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/agents/print_nodes.py +0 -0
  39. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/agents/prompt.py +0 -0
  40. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/app.py +0 -0
  41. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/auth/__init__.py +0 -0
  42. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/auth/auth.py +0 -0
  43. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/chainlit.md +0 -0
  44. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/compliance/__init__.py +0 -0
  45. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/compliance/private_data.py +0 -0
  46. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/context.py +0 -0
  47. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/db/__init__.py +0 -0
  48. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/db/database.py +0 -0
  49. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/db/vector_db.py +0 -0
  50. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/evals/__init__.py +0 -0
  51. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/evals/__main__.py +0 -0
  52. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/evals/dataset.py +0 -0
  53. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/evals/discovery.py +0 -0
  54. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/evals/run_evals.py +0 -0
  55. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/google/client.py +0 -0
  56. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/log_view/__init__.py +0 -0
  57. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/log_view/app.py +0 -0
  58. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/log_view/display.py +0 -0
  59. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/log_view/export.py +0 -0
  60. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/log_view/filters.py +0 -0
  61. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/log_view/log_utils.py +0 -0
  62. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/log_view/node_summary.py +0 -0
  63. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/logfilters/__init__.py +0 -0
  64. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/logfilters/context_filter.py +0 -0
  65. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/logging/__init__.py +0 -0
  66. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/logging/logging_config.py +0 -0
  67. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/logging/mcp_log_models.py +0 -0
  68. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/logging/mcp_logger.py +0 -0
  69. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/logging/open_telemetry.py +0 -0
  70. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/mcp/__init__.py +0 -0
  71. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/mcp/example_client.py +0 -0
  72. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/mcp/example_server.py +0 -0
  73. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/mcp/exceptions.py +0 -0
  74. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/mcp/fast_mcp_log.py +0 -0
  75. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/mcp/faulty_mcp.py +0 -0
  76. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/model_patch/model_patch.py +0 -0
  77. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/server/__init__.py +0 -0
  78. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/server/app_mounter.py +0 -0
  79. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/server/path.py +0 -0
  80. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/server/utils.py +0 -0
  81. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/testing/__init__.py +0 -0
  82. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/testing/aix_test_model.py +0 -0
  83. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/testing/mock_tool.py +0 -0
  84. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/testing/model_patch_cache.py +0 -0
  85. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/tools/doctor/__init__.py +0 -0
  86. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/tools/doctor/mcp_tool_doctor.py +0 -0
  87. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/tools/doctor/tool_doctor.py +0 -0
  88. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/tools/doctor/tool_recommendation.py +0 -0
  89. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/utils/__init__.py +0 -0
  90. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/utils/chainlit/cl_agent_show.py +0 -0
  91. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/utils/chainlit/cl_utils.py +0 -0
  92. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/utils/config.py +0 -0
  93. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/utils/config_util.py +0 -0
  94. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/utils/enum_with_description.py +0 -0
  95. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/utils/files.py +0 -0
  96. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/utils/persisted_dict.py +0 -0
  97. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/vault/__init__.py +0 -0
  98. {aixtools-0.2.14 → aixtools-0.2.16}/aixtools/vault/vault.py +0 -0
  99. {aixtools-0.2.14 → aixtools-0.2.16}/pyproject.toml +0 -0
  100. {aixtools-0.2.14 → aixtools-0.2.16}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aixtools
3
- Version: 0.2.14
3
+ Version: 0.2.16
4
4
  Summary: Tools for AI exploration and debugging
5
5
  Requires-Python: >=3.11.2
6
6
  Description-Content-Type: text/markdown
@@ -63,6 +63,7 @@ Testing Tools & Evals
63
63
  - Tool Doctor System - `aixtools/tools/doctor/`
64
64
  - Tool Recommendation Engine - `aixtools/tools/doctor/tool_recommendation.py`
65
65
  - FaultyMCP - `aixtools/mcp/faulty_mcp.py`
66
+ - Agent Mock - `aixtools/testing/agent_mock.py`
66
67
 
67
68
  Databases
68
69
  - Database Integration - `aixtools/db/`
@@ -524,6 +525,21 @@ The framework includes a custom scoring system with [`average_assertions`](aixto
524
525
 
525
526
  AIXtools provides comprehensive testing utilities and diagnostic tools for AI agent development and debugging.
526
527
 
528
+ ### Running Tests
529
+
530
+ Execute the test suite using the provided scripts:
531
+
532
+ ```bash
533
+ # Run all tests
534
+ ./scripts/test.sh
535
+
536
+ # Run unit tests only
537
+ ./scripts/test_unit.sh
538
+
539
+ # Run integration tests only
540
+ ./scripts/test_integration.sh
541
+ ```
542
+
527
543
  ### Testing Utilities
528
544
 
529
545
  The testing module provides mock tools, model patching, and test utilities for comprehensive agent testing.
@@ -544,10 +560,6 @@ cached_response = cache.get_cached_response("test_prompt")
544
560
  test_model = AixTestModel()
545
561
  ```
546
562
 
547
- ### Tool Doctor System
548
-
549
- Automated tool analysis and recommendation system for optimizing agent tool usage and analyzing MCP servers.
550
-
551
563
  #### MCP Tool Doctor
552
564
 
553
565
  Analyze tools from MCP (Model Context Protocol) servers and receive AI-powered recommendations for improvement.
@@ -586,7 +598,7 @@ tool_doctor_mcp --stdio-command fastmcp --stdio-args run my_server.py --debug
586
598
  # --debug Enable debug output
587
599
  ```
588
600
 
589
- #### Traditional Tool Doctor
601
+ #### Tool Doctor
590
602
 
591
603
  Analyze tool usage patterns from agent logs and get optimization recommendations.
592
604
 
@@ -681,19 +693,27 @@ python -m aixtools.mcp.faulty_mcp \
681
693
  --prob-on-get-crash 0.1
682
694
  ```
683
695
 
684
- ### Running Tests
696
+ ### Agent Mock
685
697
 
686
- Execute the test suite using the provided scripts:
698
+ Replay previously recorded agent runs without executing the actual agent. Useful for testing, debugging, and creating reproducible test cases.
687
699
 
688
- ```bash
689
- # Run all tests
690
- ./scripts/test.sh
700
+ ```python
701
+ from aixtools.testing.agent_mock import AgentMock
702
+ from aixtools.agents.agent import get_agent, run_agent
691
703
 
692
- # Run unit tests only
693
- ./scripts/test_unit.sh
704
+ # Run an agent and capture its execution
705
+ agent = get_agent(system_prompt="You are a helpful assistant.")
706
+ result, nodes = await run_agent(agent, "Explain quantum computing")
694
707
 
695
- # Run integration tests only
696
- ./scripts/test_integration.sh
708
+ # Create a mock agent from the recorded nodes
709
+ agent_mock = AgentMock(nodes=nodes, result_output=result)
710
+
711
+ # Save the mock for later use
712
+ agent_mock.save(Path("test_data/quantum_mock.pkl"))
713
+
714
+ # Load and replay the mock agent
715
+ loaded_mock = AgentMock.load(Path("test_data/quantum_mock.pkl"))
716
+ result, nodes = await run_agent(loaded_mock, "any prompt") # Returns recorded nodes
697
717
  ```
698
718
 
699
719
  ## Chainlit & HTTP Server
@@ -30,6 +30,7 @@ Testing Tools & Evals
30
30
  - Tool Doctor System - `aixtools/tools/doctor/`
31
31
  - Tool Recommendation Engine - `aixtools/tools/doctor/tool_recommendation.py`
32
32
  - FaultyMCP - `aixtools/mcp/faulty_mcp.py`
33
+ - Agent Mock - `aixtools/testing/agent_mock.py`
33
34
 
34
35
  Databases
35
36
  - Database Integration - `aixtools/db/`
@@ -491,6 +492,21 @@ The framework includes a custom scoring system with [`average_assertions`](aixto
491
492
 
492
493
  AIXtools provides comprehensive testing utilities and diagnostic tools for AI agent development and debugging.
493
494
 
495
+ ### Running Tests
496
+
497
+ Execute the test suite using the provided scripts:
498
+
499
+ ```bash
500
+ # Run all tests
501
+ ./scripts/test.sh
502
+
503
+ # Run unit tests only
504
+ ./scripts/test_unit.sh
505
+
506
+ # Run integration tests only
507
+ ./scripts/test_integration.sh
508
+ ```
509
+
494
510
  ### Testing Utilities
495
511
 
496
512
  The testing module provides mock tools, model patching, and test utilities for comprehensive agent testing.
@@ -511,10 +527,6 @@ cached_response = cache.get_cached_response("test_prompt")
511
527
  test_model = AixTestModel()
512
528
  ```
513
529
 
514
- ### Tool Doctor System
515
-
516
- Automated tool analysis and recommendation system for optimizing agent tool usage and analyzing MCP servers.
517
-
518
530
  #### MCP Tool Doctor
519
531
 
520
532
  Analyze tools from MCP (Model Context Protocol) servers and receive AI-powered recommendations for improvement.
@@ -553,7 +565,7 @@ tool_doctor_mcp --stdio-command fastmcp --stdio-args run my_server.py --debug
553
565
  # --debug Enable debug output
554
566
  ```
555
567
 
556
- #### Traditional Tool Doctor
568
+ #### Tool Doctor
557
569
 
558
570
  Analyze tool usage patterns from agent logs and get optimization recommendations.
559
571
 
@@ -648,19 +660,27 @@ python -m aixtools.mcp.faulty_mcp \
648
660
  --prob-on-get-crash 0.1
649
661
  ```
650
662
 
651
- ### Running Tests
663
+ ### Agent Mock
652
664
 
653
- Execute the test suite using the provided scripts:
665
+ Replay previously recorded agent runs without executing the actual agent. Useful for testing, debugging, and creating reproducible test cases.
654
666
 
655
- ```bash
656
- # Run all tests
657
- ./scripts/test.sh
667
+ ```python
668
+ from aixtools.testing.agent_mock import AgentMock
669
+ from aixtools.agents.agent import get_agent, run_agent
658
670
 
659
- # Run unit tests only
660
- ./scripts/test_unit.sh
671
+ # Run an agent and capture its execution
672
+ agent = get_agent(system_prompt="You are a helpful assistant.")
673
+ result, nodes = await run_agent(agent, "Explain quantum computing")
661
674
 
662
- # Run integration tests only
663
- ./scripts/test_integration.sh
675
+ # Create a mock agent from the recorded nodes
676
+ agent_mock = AgentMock(nodes=nodes, result_output=result)
677
+
678
+ # Save the mock for later use
679
+ agent_mock.save(Path("test_data/quantum_mock.pkl"))
680
+
681
+ # Load and replay the mock agent
682
+ loaded_mock = AgentMock.load(Path("test_data/quantum_mock.pkl"))
683
+ result, nodes = await run_agent(loaded_mock, "any prompt") # Returns recorded nodes
664
684
  ```
665
685
 
666
686
  ## Chainlit & HTTP Server
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.2.14'
32
- __version_tuple__ = version_tuple = (0, 2, 14)
31
+ __version__ = version = '0.2.16'
32
+ __version_tuple__ = version_tuple = (0, 2, 16)
33
33
 
34
- __commit_id__ = commit_id = 'gafbc9d3a4'
34
+ __commit_id__ = commit_id = 'gbf6656c3c'
@@ -70,6 +70,7 @@ def _get_model_openai_azure(
70
70
  azure_openai_api_key=AZURE_OPENAI_API_KEY,
71
71
  azure_openai_endpoint=AZURE_OPENAI_ENDPOINT,
72
72
  azure_openai_api_version=AZURE_OPENAI_API_VERSION,
73
+ http_client=None,
73
74
  ):
74
75
  assert azure_openai_endpoint, "AZURE_OPENAI_ENDPOINT is not set"
75
76
  assert azure_openai_api_key, "AZURE_OPENAI_API_KEY is not set"
@@ -79,6 +80,7 @@ def _get_model_openai_azure(
79
80
  azure_endpoint=azure_openai_endpoint,
80
81
  api_version=azure_openai_api_version,
81
82
  api_key=azure_openai_api_key,
83
+ http_client=http_client,
82
84
  )
83
85
  return OpenAIChatModel(model_name=model_name, provider=OpenAIProvider(openai_client=client))
84
86
 
@@ -101,9 +103,9 @@ def get_model(model_family=MODEL_FAMILY, model_name=None, http_client=None, **kw
101
103
  assert model_family is not None and model_family != "", f"Model family '{model_family}' is not set"
102
104
  match model_family:
103
105
  case "azure":
104
- return _get_model_openai_azure(model_name=model_name or AZURE_MODEL_NAME, **kwargs)
106
+ return _get_model_openai_azure(model_name=model_name or AZURE_MODEL_NAME, http_client=http_client, **kwargs)
105
107
  case "bedrock":
106
- return _get_model_bedrock(model_name=model_name or BEDROCK_MODEL_NAME, **kwargs)
108
+ return _get_model_bedrock(model_name=model_name or BEDROCK_MODEL_NAME, http_client=http_client, **kwargs)
107
109
  case "ollama":
108
110
  return _get_model_ollama(model_name=model_name or OLLAMA_MODEL_NAME, http_client=http_client, **kwargs)
109
111
  case "openai":
@@ -0,0 +1,201 @@
1
+ """Convert Pydantic-AI Nodes to Markdown format."""
2
+
3
+ from pydantic_ai import CallToolsNode, ModelRequestNode, UserPromptNode
4
+ from pydantic_ai.messages import (
5
+ RetryPromptPart,
6
+ SystemPromptPart,
7
+ TextPart,
8
+ ToolCallPart,
9
+ ToolReturnPart,
10
+ UserPromptPart,
11
+ )
12
+ from pydantic_graph.nodes import End
13
+
14
+ from aixtools.agents.nodes_to_message import NodesToMessage
15
+ from aixtools.utils.utils import is_multiline, is_too_long, to_json_pretty_print
16
+
17
+ MAX_TITLE_LENGTH = 30
18
+
19
+
20
+ class NodesToMarkdown(NodesToMessage):
21
+ """
22
+ Convert Pydantic-AI Nodes to Markdown
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ user_prompt_node: bool = True,
28
+ user_prompt_part: bool = True,
29
+ system_prompt_part: bool = True,
30
+ title_max_length: int = MAX_TITLE_LENGTH,
31
+ ):
32
+ """
33
+ Initialize the NodeToMarkdown converter.
34
+ Args:
35
+ user_prompt_node: Whether to include UserPromptNode content
36
+ user_prompt_part: Whether to include UserPromptPart content
37
+ system_prompt_part: Whether to include SystemPromptPart content
38
+ """
39
+ super().__init__(
40
+ user_prompt_node=user_prompt_node,
41
+ user_prompt_part=user_prompt_part,
42
+ system_prompt_part=system_prompt_part,
43
+ )
44
+ self.title_max_length = title_max_length
45
+
46
+ def to_str(self, nodes) -> str | None:
47
+ """Convert a node to its markdown string representation."""
48
+ _, title, md = self.to_markdown(nodes)
49
+ return f"# {title}\n\n{md}"
50
+
51
+ def to_markdown(self, node) -> tuple[str | None, str | None, str | None]:
52
+ """
53
+ Get a name, title, and markdown representation of a node.
54
+
55
+ Returns:
56
+ name: A short name for the node
57
+ title: A title for the node
58
+ node_md: A markdown representation of the node or None
59
+
60
+ Note: all values can be None if the node should not be converted (e.g., user prompt disabled).
61
+ """
62
+ node_md = self.node2md(node)
63
+ if node_md is None:
64
+ return None, None, None
65
+ name = self.node2name(node)
66
+ title = self.node2title(node)
67
+ return name, title, node_md
68
+
69
+ def _format_prompt_part(self, label: str, content: str, enabled: bool) -> str | None:
70
+ """Format UserPromptPart or SystemPromptPart with multiline handling."""
71
+ if not enabled:
72
+ return None
73
+ if is_multiline(content): # type: ignore
74
+ return f"### {label}\n{content}\n"
75
+ return f"{label}: {content}\n"
76
+
77
+ def _part2md(self, p) -> str | None: # noqa: PLR0911 # pylint: disable=too-many-return-statements
78
+ """Convert a Part to a string representation."""
79
+ match p:
80
+ case ToolCallPart():
81
+ return f"### Tool `{p.tool_name}`\n```json\n{to_json_pretty_print(p.args)}\n```\n"
82
+ case TextPart():
83
+ return f"### Text\n{p.content}\n" if is_multiline(p.content) else f"{p.content}\n"
84
+ case ToolReturnPart():
85
+ if is_multiline(p.content) or is_too_long(p.content):
86
+ return f"### Tool return `{p.tool_name}`\n```json\n{to_json_pretty_print(p.content)}\n```\n"
87
+ return f"Tool return `{p.tool_name}`: `{p.content}`"
88
+ case UserPromptPart():
89
+ return self._format_prompt_part("UserPromptPart", p.content, self.user_prompt_part) # type: ignore
90
+ case SystemPromptPart():
91
+ return self._format_prompt_part("SystemPromptPart", p.content, self.system_prompt_part) # type: ignore
92
+ case RetryPromptPart():
93
+ return f"### RetryPromptPart `{p.tool_name}`\n{p.content}\n"
94
+ case _:
95
+ return f"### Part {type(p)}\n{p}"
96
+
97
+ def _part2title(self, p) -> str: # noqa: PLR0911 # pylint: disable=too-many-return-statements
98
+ """Convert a Part to a title representation."""
99
+ match p:
100
+ case ToolCallPart():
101
+ return f"Tool `{p.tool_name}`"
102
+ case TextPart():
103
+ return self._to_title_length(p.content)
104
+ case ToolReturnPart():
105
+ return f"Tool return `{p.tool_name}`"
106
+ case UserPromptPart():
107
+ return f"UserPromptPart: {self._to_title_length(p.content)}\n"
108
+ case SystemPromptPart():
109
+ return f"SystemPromptPart: {self._to_title_length(p.content)}\n"
110
+ case RetryPromptPart():
111
+ return f"WARNING: Retry {p.tool_name}"
112
+ case _:
113
+ return f"{type(p)}: {self._to_title_length(p)}"
114
+
115
+ def _parts2md(self, parts) -> str | None:
116
+ """Convert to string a list of Parts with a given prefix."""
117
+ if len(parts) == 0:
118
+ return None
119
+
120
+ if len(parts) == 1:
121
+ s = self._part2md(parts[0])
122
+ if s is None:
123
+ return None
124
+ return s + "\n"
125
+
126
+ result = ""
127
+ for p in parts:
128
+ s = self._part2md(p)
129
+ if s is not None:
130
+ result += s + "\n"
131
+ return result
132
+
133
+ def _parts2title(self, parts) -> str:
134
+ """Convert to title a list of Parts with a given prefix."""
135
+ if len(parts) == 0:
136
+ return ""
137
+
138
+ if len(parts) == 1:
139
+ return self._part2title(parts[0])
140
+
141
+ result = ""
142
+ for p in parts:
143
+ s = self._part2title(p)
144
+ # Prefer 'Tool' titles
145
+ if s.startswith("ERROR"):
146
+ return s
147
+ if s.startswith("Tool"):
148
+ return s
149
+ if not result:
150
+ result = s
151
+
152
+ return result
153
+
154
+ def node2md(self, n) -> str | None:
155
+ """Convert a node in a markdown format"""
156
+ match n:
157
+ case UserPromptNode():
158
+ return f"# UserPrompt\n{n.user_prompt}\n" if self.user_prompt_node else None
159
+ case CallToolsNode():
160
+ return f"# Call tools\n{self._parts2md(n.model_response.parts)}\n"
161
+ case ModelRequestNode():
162
+ return f"# Model request\n{self._parts2md(n.request.parts)}\n"
163
+ case End():
164
+ return f"# End\n{n.data.output}\n"
165
+ case RetryPromptPart():
166
+ return "# Retry tool"
167
+ case _:
168
+ return f"{type(n)}: {n}"
169
+
170
+ def node2title(self, n) -> str | None:
171
+ """Get a title for a node."""
172
+ match n:
173
+ case UserPromptNode():
174
+ return self._to_title_length(n.user_prompt)
175
+ case CallToolsNode():
176
+ return self._parts2title(n.model_response.parts)
177
+ case ModelRequestNode():
178
+ return self._parts2title(n.request.parts)
179
+ case End():
180
+ return self._to_title_length(n.data.output)
181
+ case _:
182
+ return f"{type(n)}: {n}"
183
+
184
+ def node2name(self, n) -> str | None:
185
+ """Get a short name for a node."""
186
+ match n:
187
+ case UserPromptNode():
188
+ return "UserPrompt"
189
+ case CallToolsNode():
190
+ return "Call tools"
191
+ case ModelRequestNode():
192
+ return "Model request"
193
+ case End():
194
+ return "End"
195
+ case _:
196
+ return f"{type(n)}: {n}"
197
+
198
+ def _to_title_length(self, s) -> str:
199
+ """Truncate a string to a maximum length for title display."""
200
+ s = str(s).replace("\n", " ").strip()
201
+ return s[: self.title_max_length] + "..." if len(s) > self.title_max_length else s
@@ -0,0 +1,30 @@
1
+ """Convert Pydantic-AI Nodes to Markdown format."""
2
+
3
+ from abc import ABC
4
+
5
+
6
+ class NodesToMessage(ABC): # pylint: disable=too-few-public-methods
7
+ """
8
+ Convert Pydantic-AI Nodes to Message format
9
+ """
10
+
11
+ def __init__(
12
+ self,
13
+ user_prompt_node: bool = True,
14
+ user_prompt_part: bool = True,
15
+ system_prompt_part: bool = True,
16
+ ):
17
+ """
18
+ Initialize the NodeToMessage converter.
19
+ Args:
20
+ user_prompt_node: Whether to include UserPromptNode content
21
+ user_prompt_part: Whether to include UserPromptPart content
22
+ system_prompt_part: Whether to include SystemPromptPart content
23
+ """
24
+ self.user_prompt_node = user_prompt_node
25
+ self.user_prompt_part = user_prompt_part
26
+ self.system_prompt_part = system_prompt_part
27
+
28
+ def to_str(self, nodes) -> str | None:
29
+ """Convert a node to its string representation."""
30
+ raise NotImplementedError("to_str method must be implemented by subclasses")
@@ -0,0 +1,109 @@
1
+ """Convert Pydantic-AI Nodes to String format."""
2
+
3
+ from collections.abc import Iterable
4
+
5
+ from pydantic_ai import CallToolsNode, ModelRequestNode, UserPromptNode
6
+ from pydantic_ai.messages import SystemPromptPart, TextPart, ToolCallPart, ToolReturnPart, UserPromptPart
7
+ from pydantic_graph.nodes import End
8
+
9
+ from aixtools.agents.nodes_to_message import NodesToMessage
10
+ from aixtools.utils.utils import is_multiline, tabit
11
+
12
+
13
+ def print_nodes(nodes):
14
+ """Convert a list of nodes in a readable format."""
15
+ n2s = NodesToString()
16
+ print(n2s.to_str(nodes))
17
+
18
+
19
+ class NodesToString(NodesToMessage): # pylint: disable=too-few-public-methods
20
+ """
21
+ Convert Pydantic-AI Nodes to String
22
+ """
23
+
24
+ def __init__(self, user_prompt_node: bool = True, user_prompt_part: bool = True, system_prompt_part: bool = True):
25
+ """
26
+ Initialize the NodeToMarkdown converter.
27
+ Args:
28
+ user_prompt_node: Whether to include UserPromptNode content
29
+ user_prompt_part: Whether to include UserPromptPart content
30
+ system_prompt_part: Whether to include SystemPromptPart content
31
+ """
32
+ super().__init__(
33
+ user_prompt_node=user_prompt_node,
34
+ user_prompt_part=user_prompt_part,
35
+ system_prompt_part=system_prompt_part,
36
+ )
37
+
38
+ def _format_content(self, label: str, content: str, prefix: str) -> str:
39
+ """Format content with optional multiline handling."""
40
+ if is_multiline(content):
41
+ pre = f"{prefix}\t|"
42
+ return f"{prefix}{label}:\n{tabit(content, pre)}"
43
+ return f"{prefix}{label}: {content}"
44
+
45
+ def _part2str(self, p, prefix: str = "\t") -> str | None:
46
+ """Convert a Part to a string representation."""
47
+ match p:
48
+ case ToolCallPart():
49
+ return f"{prefix}Tool: {p.tool_name}, args: {p.args}"
50
+ case TextPart():
51
+ return self._format_content("Text", p.content, prefix)
52
+ case ToolReturnPart():
53
+ return f"{prefix}Tool return: {p.tool_name}, content: {p.content}"
54
+ case UserPromptPart():
55
+ return None if not self.user_prompt_part else self._format_content("UserPromptPart", p.content, prefix) # type: ignore # pylint: disable=line-too-long
56
+ case SystemPromptPart():
57
+ return (
58
+ None if not self.system_prompt_part else self._format_content("SystemPromptPart", p.content, prefix)
59
+ ) # type: ignore
60
+ case _:
61
+ return f"{prefix}Part {type(p)}: {p}"
62
+
63
+ def _parts2str(self, parts, prefix: str = "") -> str:
64
+ """Convert to string a list of Parts with a given prefix."""
65
+ if len(parts) == 0:
66
+ return f"{prefix}No parts\n"
67
+
68
+ if len(parts) == 1:
69
+ s = self._part2str(parts[0], prefix=prefix)
70
+ return s + "\n" if s else ""
71
+
72
+ result = ""
73
+ for i, p in enumerate(parts):
74
+ s = self._part2str(p, prefix=f"{prefix}{i}: ")
75
+ result += f"{s}\n" if s else ""
76
+ return result
77
+
78
+ def _node2str(self, n) -> str:
79
+ """Convert a node in a readable format."""
80
+ match n:
81
+ case UserPromptNode():
82
+ assert n.user_prompt is not None
83
+
84
+ return (
85
+ f"UserPrompt:\n{tabit(n.user_prompt)}\n"
86
+ if n.user_prompt is not None and self.user_prompt_node
87
+ else ""
88
+ )
89
+ case CallToolsNode():
90
+ parts_str = self._parts2str(n.model_response.parts, prefix="\t")
91
+ return f"Call tools:\n{parts_str}\n"
92
+ case ModelRequestNode():
93
+ parts_str = self._parts2str(n.request.parts, prefix="\t")
94
+ return f"Model request:\n{parts_str}\n"
95
+ case End():
96
+ return f"End:\n{tabit(n.data.output)}\n"
97
+ case _:
98
+ return f"{type(n)}: {n}"
99
+
100
+ def to_str(self, nodes) -> str:
101
+ """Convert a list of nodes in a readable format."""
102
+ out = ""
103
+ if isinstance(nodes, Iterable) and not isinstance(nodes, (str, bytes)):
104
+ for n in nodes:
105
+ out += self._node2str(n)
106
+ else:
107
+ # Assume it's a single node
108
+ out += self._node2str(nodes)
109
+ return out
@@ -38,10 +38,13 @@ class ExceptionWrapper: # pylint: disable=too-few-public-methods
38
38
  return f"{self.exc_type}: {self.exc_value}\n{self.exc_traceback}"
39
39
 
40
40
 
41
- def is_pickleable(obj):
41
+ def is_pickleable(obj, use_cache: bool = False):
42
42
  """
43
43
  Check if an object is pickleable.
44
- Uses a cache to avoid repeated checks for the same type.
44
+
45
+ use_cache: If True, use the cache to store results of previous checks.
46
+ Why? Some complex objects may have lists of multiple types
47
+ inside, so whether an object is pickleable may depend on its contents.
45
48
  """
46
49
  obj_type = type(obj)
47
50
  module_name = getattr(obj_type, "__module__", "")
@@ -50,7 +53,7 @@ def is_pickleable(obj):
50
53
  if module_name == "fastmcp.utilities.json_schema_type":
51
54
  return False
52
55
 
53
- if obj_type not in _is_pickleable_cache:
56
+ if not use_cache or obj_type not in _is_pickleable_cache:
54
57
  try:
55
58
  pickle.loads(pickle.dumps(obj))
56
59
  _is_pickleable_cache[obj_type] = True
@@ -76,42 +79,50 @@ def load_from_log(log_file: Path):
76
79
  return objects
77
80
 
78
81
 
79
- def safe_deepcopy(obj):
82
+ def safe_deepcopy(obj, use_cache: bool = False):
80
83
  """
81
84
  A safe deepcopy function that handles unpickleable objects.
82
85
  It uses 'is_pickleable' to check if the object is serializable and
83
86
  performs a shallow copy for unpickleable objects.
87
+
88
+ Note: If the object is complex (e.g. has a list or dict of varying objects), using the
89
+ cache may lead to bad results.
90
+ For example you analyze an object with an empty list first, then another object which is non-pickable
91
+ is added, but you use the cache result, resulting in the wrong assumption that the object is pickleable
92
+ So by default the cache is disabled.
84
93
  """
85
94
  if isinstance(obj, Exception):
86
95
  # Wrap exceptions to make them pickleable
87
96
  obj = ExceptionWrapper(obj)
88
97
 
89
- if is_pickleable(obj):
98
+ if is_pickleable(obj, use_cache=use_cache):
90
99
  return pickle.loads(pickle.dumps(obj)) # Fast path
91
100
 
92
101
  if isinstance(obj, Mapping):
93
- return {k: safe_deepcopy(v) for k, v in obj.items() if is_pickleable(k)}
102
+ return {
103
+ k: safe_deepcopy(v, use_cache=use_cache) for k, v in obj.items() if is_pickleable(k, use_cache=use_cache)
104
+ }
94
105
 
95
106
  if isinstance(obj, Sequence) and not isinstance(obj, str):
96
- return [safe_deepcopy(item) for item in obj]
107
+ return [safe_deepcopy(item, use_cache=use_cache) for item in obj]
97
108
 
98
109
  if hasattr(obj, "__dict__"):
99
110
  copy_obj = copy(obj)
100
111
  for attr, value in vars(obj).items():
101
- if is_pickleable(value):
102
- setattr(copy_obj, attr, safe_deepcopy(value))
112
+ if is_pickleable(value, use_cache=use_cache):
113
+ setattr(copy_obj, attr, value)
103
114
  else:
104
- setattr(copy_obj, attr, None) # Remove unpickleable field
115
+ setattr(copy_obj, attr, safe_deepcopy(value, use_cache=use_cache))
105
116
  return copy_obj
106
117
 
107
118
  return None # fallback for non-serializable, non-introspectable objects
108
119
 
109
120
 
110
- def save_objects_to_logfile(objects: list, log_dir=LOGS_DIR):
121
+ async def save_objects_to_logfile(objects: list, log_dir=LOGS_DIR):
111
122
  """Save the objects to a (pickle) log file"""
112
123
  with ObjectLogger(log_dir=log_dir) as object_logger:
113
124
  for obj in objects:
114
- object_logger.log(obj)
125
+ await object_logger.log(obj)
115
126
 
116
127
 
117
128
  class BaseLogger:
@@ -144,7 +155,7 @@ class ObjectLogger(BaseLogger):
144
155
  log_dir=LOGS_DIR,
145
156
  verbose: bool = True,
146
157
  debug: bool | None = None,
147
- parent_logger: Union["ObjectLogger", NoneType] = None,
158
+ parent_logger: Union["BaseLogger", NoneType] = None,
148
159
  ):
149
160
  self.verbose = verbose
150
161
  self.debug = (
@@ -195,8 +206,9 @@ class ObjectLogger(BaseLogger):
195
206
  elif self.verbose:
196
207
  print(obj, flush=True)
197
208
  obj_to_save = safe_deepcopy(obj)
198
- pickle.dump(obj_to_save, self.file)
199
- self.file.flush() # ensure it's written immediately
209
+ if self.file is not None:
210
+ pickle.dump(obj_to_save, self.file)
211
+ self.file.flush() # ensure it's written immediately
200
212
  except Exception as e: # pylint: disable=broad-exception-caught
201
213
  logger.error("Failed to log object: %s", e)
202
214
  logger.error(traceback.format_exc())