aixtools 0.2.15__py3-none-any.whl → 0.2.17__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.

Potentially problematic release.


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

aixtools/_version.py CHANGED
@@ -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.15'
32
- __version_tuple__ = version_tuple = (0, 2, 15)
31
+ __version__ = version = '0.2.17'
32
+ __version_tuple__ = version_tuple = (0, 2, 17)
33
33
 
34
34
  __commit_id__ = commit_id = None
aixtools/agents/agent.py CHANGED
@@ -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,32 +79,40 @@ 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
@@ -0,0 +1,54 @@
1
+ """
2
+ Custom middleware for MCP servers
3
+ """
4
+
5
+ import traceback
6
+
7
+ from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware
8
+ from fastmcp.server.middleware.middleware import MiddlewareContext
9
+
10
+ from aixtools.mcp import AixToolError
11
+
12
+
13
+ class AixErrorHandlingMiddleware(ErrorHandlingMiddleware):
14
+ """Custom middleware class for handling errors in MCP servers."""
15
+
16
+ def log_as_warn_with_traceback(
17
+ self, *, error: Exception, original_error: Exception, context: MiddlewareContext
18
+ ) -> None:
19
+ """Logs provided error as warning.
20
+ original_error is an 'unwrapped' error if applicable, otherwise can be the same as error param.
21
+ """
22
+ error_type = type(original_error).__name__
23
+ method = context.method or "unknown"
24
+ error_key = f"{error_type}:{method}"
25
+ self.error_counts[error_key] = self.error_counts.get(error_key, 0) + 1
26
+ base_message = f"{method} resulted in {error_type}: {str(error)}"
27
+ if self.include_traceback:
28
+ self.logger.warning(f"{base_message}\n{traceback.format_exc()}")
29
+ if self.error_callback:
30
+ try:
31
+ self.error_callback(error, context)
32
+ except Exception as callback_error: # pylint: disable=broad-exception-caught
33
+ self.logger.warning("Callback failed spectacularly: %s", callback_error)
34
+
35
+ def handle_error(self, error: Exception, context: MiddlewareContext) -> bool:
36
+ """Custom error logging"""
37
+ if isinstance(error, AixToolError):
38
+ self.log_as_warn_with_traceback(error=error, original_error=error, context=context)
39
+ return True
40
+
41
+ inner_error = error
42
+ while hasattr(inner_error, "__cause__") and inner_error.__cause__ is not None:
43
+ inner_error = inner_error.__cause__
44
+ if isinstance(inner_error, AixToolError):
45
+ self.log_as_warn_with_traceback(error=error, original_error=inner_error, context=context)
46
+ return True
47
+
48
+ return False
49
+
50
+ def _log_error(self, error: Exception, context: MiddlewareContext) -> None:
51
+ """Override original _log_error method."""
52
+ if self.handle_error(error, context):
53
+ return
54
+ super()._log_error(error, context)
@@ -0,0 +1,143 @@
1
+ """Mock agent that replays previously recorded nodes for testing purposes."""
2
+
3
+ import pickle
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from aixtools.logging.log_objects import safe_deepcopy
8
+
9
+
10
+ class AgentMock:
11
+ """
12
+ Mock agent that replays previously recorded nodes.
13
+ Used for testing or replaying agent runs without executing the actual agent.
14
+
15
+ Example:
16
+
17
+ # Run an agent and save its nodes
18
+ agent = get_agent(...)
19
+ ret, nodes = await run_agent(agent, prompt, ...)
20
+
21
+ # Use AgentMock to replay the nodes
22
+ agent_mock = AgentMock(nodes=nodes, result_output=ret)
23
+
24
+ # Now we can use agent_mock in place of the original agent
25
+ ret, nodes = await run_agent(agent_mock, prompt, ...)
26
+
27
+ # Save the mock agent to a file
28
+ agent_mock.save(Path("agent_mock.pkl"))
29
+ """
30
+
31
+ def __init__(self, nodes: list[Any], result_output: str | None = None):
32
+ """
33
+ Initialize the mock agent with pre-recorded nodes.
34
+
35
+ Args:
36
+ nodes: List of nodes from a previous agent run
37
+ result_output: Optional output to return as the final result
38
+ """
39
+ self.nodes = nodes
40
+ self.result_output = result_output
41
+
42
+ def save(self, path: Path) -> None:
43
+ """
44
+ Save the mock agent to a file.
45
+ Uses safe_deepcopy to ensure nodes are serializable.
46
+
47
+ Args:
48
+ path: Path to save the agent mock data
49
+ """
50
+ path.parent.mkdir(parents=True, exist_ok=True)
51
+ data = {"nodes": safe_deepcopy(self.nodes, use_cache=False), "result_output": self.result_output} # pylint: disable=unexpected-keyword-arg
52
+ with open(path, "wb") as f:
53
+ pickle.dump(data, f)
54
+
55
+ @classmethod
56
+ def load(cls, path: Path) -> "AgentMock":
57
+ """
58
+ Load a mock agent from a file.
59
+
60
+ Args:
61
+ path: Path to load the agent mock data from
62
+
63
+ Returns:
64
+ AgentMock instance with loaded data
65
+ """
66
+ with open(path, "rb") as f:
67
+ data = pickle.load(f)
68
+ return cls(nodes=data["nodes"], result_output=data["result_output"])
69
+
70
+ async def __aenter__(self):
71
+ """Async context manager entry."""
72
+ return self
73
+
74
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
75
+ """Async context manager exit."""
76
+
77
+ def iter(self, **kwargs) -> "AgentRunMock": # pylint: disable=unused-argument
78
+ """
79
+ Create an async iterator that replays the recorded nodes.
80
+
81
+ Args:
82
+ prompt: The prompt (ignored in mock, kept for interface compatibility)
83
+ usage_limits: Usage limits (ignored in mock, kept for interface compatibility)
84
+
85
+ Returns:
86
+ An async iterator that yields the recorded nodes
87
+ """
88
+ return AgentRunMock(self.nodes, self.result_output)
89
+
90
+
91
+ class AgentRunMock:
92
+ """
93
+ Mock agent run that yields pre-recorded nodes.
94
+ """
95
+
96
+ def __init__(self, nodes: list[Any], result_output: str | None = None):
97
+ """
98
+ Initialize the mock agent run.
99
+
100
+ Args:
101
+ nodes: List of nodes to replay
102
+ result_output: Optional output to return as the final result
103
+ """
104
+ self.nodes = nodes
105
+ self.result = MockResult(result_output)
106
+ self._index = 0
107
+
108
+ async def __aenter__(self):
109
+ """Async context manager entry."""
110
+ return self
111
+
112
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
113
+ """Async context manager exit."""
114
+
115
+ def __aiter__(self):
116
+ """Return self as async iterator."""
117
+ return self
118
+
119
+ async def __anext__(self):
120
+ """Yield the next node from the recorded list."""
121
+ if self._index >= len(self.nodes):
122
+ raise StopAsyncIteration
123
+ node = self.nodes[self._index]
124
+ self._index += 1
125
+ return node
126
+
127
+
128
+ class MockResult: # pylint: disable=too-few-public-methods
129
+ """
130
+ Mock result object that mimics the agent result structure.
131
+ """
132
+
133
+ def __init__(self, output: str | None = None):
134
+ """
135
+ Initialize the mock result.
136
+
137
+ Args:
138
+ output: The output to return
139
+ """
140
+ self.output = output
141
+
142
+ def __str__(self):
143
+ return f"MockResult(output={self.output})"
aixtools/utils/utils.py CHANGED
@@ -32,6 +32,18 @@ def find_file(path: Path, glob="*.pdf"):
32
32
  yield from path.rglob(glob)
33
33
 
34
34
 
35
+ def is_multiline(s: str) -> bool:
36
+ """Check if a string is multiline."""
37
+ s = str(s).rstrip()
38
+ return "\n" in s
39
+
40
+
41
+ def is_too_long(s: str, max_length: int = 200) -> bool:
42
+ """Check if a string is too long."""
43
+ s = str(s).rstrip()
44
+ return len(s) > max_length
45
+
46
+
35
47
  def prepend_all_lines(msg, prepend="\t", skip_first_line: bool = False) -> str:
36
48
  """Prepend all lines of a message with a prepend."""
37
49
  out = ""
@@ -59,7 +71,7 @@ def remove_quotes(s):
59
71
  """
60
72
  if s is None:
61
73
  return None
62
- s = s.strip()
74
+ s = str(s).strip()
63
75
  while (
64
76
  (s.startswith('"') and s.endswith('"'))
65
77
  or (s.startswith("'") and s.endswith("'"))
@@ -76,9 +88,29 @@ def remove_quotes(s):
76
88
  return s
77
89
 
78
90
 
79
- def tabit(s: str, prefix="\t|") -> str:
91
+ def tabit(s, prefix="\t|") -> str:
80
92
  """Add a prefix to each line of a string for improved readability."""
81
- return prefix + str(s).replace("\n", f"\n{prefix}")
93
+ s = str(s)
94
+ return prefix + s.replace("\n", f"\n{prefix}")
95
+
96
+
97
+ def to_json_pretty_print(obj) -> str:
98
+ """Convert to a pretty-printed JSON string if possible."""
99
+ if isinstance(obj, str):
100
+ # Already a string, try to parse as JSON, then pretty print
101
+ s = obj
102
+ try:
103
+ obj = json.loads(s)
104
+ return json.dumps(obj, indent=2)
105
+ except Exception as _: # pylint: disable=broad-exception-caught
106
+ # Not a JSON string, return as is
107
+ return s
108
+ # Not a string, convert to pretty JSON
109
+ try:
110
+ return json.dumps(obj, indent=2)
111
+ except Exception as _: # pylint: disable=broad-exception-caught
112
+ # Fallback to str representation
113
+ return str(obj)
82
114
 
83
115
 
84
116
  def to_str(data) -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aixtools
3
- Version: 0.2.15
3
+ Version: 0.2.17
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
@@ -1,5 +1,5 @@
1
1
  aixtools/__init__.py,sha256=9NGHm7LjsQmsvjTZvw6QFJexSvAU4bCoN_KBk9SCa00,260
2
- aixtools/_version.py,sha256=fu32ckCvDvmdBESK9QY8685INg5f0hTCLuUhaNOVaJQ,706
2
+ aixtools/_version.py,sha256=sRnPbdnyLakHrE7uBPRC_AQNPiFphtVIa4BPaftkqk4,706
3
3
  aixtools/app.py,sha256=JzQ0nrv_bjDQokllIlGHOV0HEb-V8N6k_nGQH-TEsVU,5227
4
4
  aixtools/chainlit.md,sha256=yC37Ly57vjKyiIvK4oUvf4DYxZCwH7iocTlx7bLeGLU,761
5
5
  aixtools/context.py,sha256=I_MD40ZnvRm5WPKAKqBUAdXIf8YaurkYUUHSVVy-QvU,598
@@ -25,8 +25,11 @@ aixtools/a2a/google_sdk/utils.py,sha256=4VIPV2GtG5IRY-KSZN6iRMS3ntxG2uKgd_d5tQvn
25
25
  aixtools/a2a/google_sdk/pydantic_ai_adapter/agent_executor.py,sha256=8VuU2WXeSHUK3_rRm_mjX6elqdC9NA2uz1aELzeC8BU,9784
26
26
  aixtools/a2a/google_sdk/pydantic_ai_adapter/storage.py,sha256=nGoVL7MPoZJW7iVR71laqpUYP308yFKZIifJtvUgpiU,878
27
27
  aixtools/agents/__init__.py,sha256=MAW196S2_G7uGqv-VNjvlOETRfuV44WlU1leO7SiR0A,282
28
- aixtools/agents/agent.py,sha256=JRbWWo3lueXyNSd9UgnTztm4bZr7dPxeNOzCFNFG7RE,7620
28
+ aixtools/agents/agent.py,sha256=loBbSrRY-QZH8wec27JjNi_h1jhqkZ9-UrR0lqPNMWI,7725
29
29
  aixtools/agents/agent_batch.py,sha256=0Zu9yNCRPAQZPjXQ-dIUAmP1uGTVbxVt7xvnMpoJMjU,2251
30
+ aixtools/agents/nodes_to_md.py,sha256=hAT8dgiZTG4uGoSgeRZkIJ7zgkQUNpdIr8KSFhjWAH0,7515
31
+ aixtools/agents/nodes_to_message.py,sha256=ZqcmxUNf4esiCTRk37wWP1LquhqNsCmydvMr4kjZEjw,1012
32
+ aixtools/agents/nodes_to_str.py,sha256=UkOu5Nry827J4H_ohQU3tPBfJxtr3p6FfCfWoUy5uIs,4325
30
33
  aixtools/agents/print_nodes.py,sha256=wVTngNfqM0As845WTRz6G3Rei_Gr3HuBlvu-G_eXuig,1665
31
34
  aixtools/agents/prompt.py,sha256=p9OYnyJ4-MyGXwHPrQeJBhZ2a3RV2HqhtdUUCrTMsAQ,3361
32
35
  aixtools/auth/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -52,10 +55,11 @@ aixtools/log_view/node_summary.py,sha256=EJjnBqdBWI-_bI-4nfTxwaost3mtiufb5cK7T54
52
55
  aixtools/logfilters/__init__.py,sha256=pTD8ujCqjPWBCeB7yv7lmCtnA2KXOnkIv0HExDagkXs,129
53
56
  aixtools/logfilters/context_filter.py,sha256=zR3Bnv3fCqXLeb7bCFTmlnWhC6dFIvUb-u712tOnUPk,2259
54
57
  aixtools/logging/__init__.py,sha256=b5oYyGQDUHHxhRtzqKUaQPv8hQeWw54rzDXSV8lDY1w,613
55
- aixtools/logging/log_objects.py,sha256=K8p2kHTqr9nntHQ0-jyNrDBoBiqH14gFnD7atAiN_do,6964
58
+ aixtools/logging/log_objects.py,sha256=gohsgcfyr8vsY7G_hfmj973-Ek1_PN-bMMLEUA-4u6U,7708
56
59
  aixtools/logging/logging_config.py,sha256=LvxV3C75-I0096PpcCIbgM-Cp998LzWXeMM14HYbU20,4985
57
60
  aixtools/logging/mcp_log_models.py,sha256=7-H2GJXiiyLhpImuyLLftAGG4skxJal8Swax0ob04MY,3463
58
61
  aixtools/logging/mcp_logger.py,sha256=d2I5l4t0d6rQH17w23FpE1IUD8Ax-mSaKfByCH86q4I,6257
62
+ aixtools/logging/mcp_middleware.py,sha256=0kpTAwvz9Fd_mFDP3J9ldH4dPP-bhcUjLJKELGOx0IQ,2257
59
63
  aixtools/logging/model_patch_logging.py,sha256=CW5-kKI-zNEgZhNV4vx3EQu6fbrEtX7VjA6fE5loRLQ,2916
60
64
  aixtools/logging/open_telemetry.py,sha256=fJjF1ou_8GyfNfbyWDQPGK6JAUrUaPwURYPHhXEtDBE,1121
61
65
  aixtools/mcp/__init__.py,sha256=Qp4uD1RtypCYgzWrt_ThBVxa5-CFBgcwMWkHbHFUQ54,232
@@ -71,6 +75,7 @@ aixtools/server/app_mounter.py,sha256=0tJ0tC140ezAjnYdlhpLJQjY-TO8NVw7D8LseYCCVY
71
75
  aixtools/server/path.py,sha256=nI4yRQcE6gjKx5GG3PmHf7iT1FelT6Q8Xhw4ol9O1e0,5219
72
76
  aixtools/server/utils.py,sha256=tZWITIx6M-luV9yve4j3rPtYGSSA6zWS0JWEAySne_M,2276
73
77
  aixtools/testing/__init__.py,sha256=mlmaAR2gmS4SbsYNCxnIprmFpFp-syjgVUkpUszo3mE,166
78
+ aixtools/testing/agent_mock.py,sha256=D4DrRGaeKuWEhq8hdB5s9PdfLAXHMKiC3JA7M8yN13o,4244
74
79
  aixtools/testing/aix_test_model.py,sha256=i0xBdmpKoEfJHle6JDmcoJLUENN8Eqt181_WZ7XtDdU,6240
75
80
  aixtools/testing/mock_tool.py,sha256=4I0LxxSkLhGIKM2YxCP3cnYI8IYJjdKhfwGZ3dioXsM,2465
76
81
  aixtools/testing/model_patch_cache.py,sha256=238gKC_gSpR3BkeejhetObOkpOR1l2Iz3A6B_eUTRNc,10158
@@ -84,13 +89,13 @@ aixtools/utils/config_util.py,sha256=3Ya4Qqhj1RJ1qtTTykQ6iayf5uxlpigPXgEJlTi1wn4
84
89
  aixtools/utils/enum_with_description.py,sha256=zjSzWxG74eR4x7dpmb74pLTYCWNSMvauHd7_9LpDYIw,1088
85
90
  aixtools/utils/files.py,sha256=8JnxwHJRJcjWCdFpjzWmo0po2fRg8esj4H7sOxElYXU,517
86
91
  aixtools/utils/persisted_dict.py,sha256=0jQzV7oF-A6Or-HjcU6V7aMXWQL67SOKpULgmtFwAfg,3110
87
- aixtools/utils/utils.py,sha256=5911Ej1ES2NU_FKIWA3CWKhKnwgjvi1aDR2aiD6Xv3E,4880
92
+ aixtools/utils/utils.py,sha256=EP4q50wuIwgICzv3unMqDS93HuuySxE7SezqxX4HdKg,5840
88
93
  aixtools/utils/chainlit/cl_agent_show.py,sha256=vaRuowp4BRvhxEr5hw0zHEJ7iaSF_5bo_9BH7pGPPpw,4398
89
94
  aixtools/utils/chainlit/cl_utils.py,sha256=fxaxdkcZg6uHdM8uztxdPowg3a2f7VR7B26VPY4t-3c,5738
90
95
  aixtools/vault/__init__.py,sha256=fsr_NuX3GZ9WZ7dGfe0gp_5-z3URxAfwVRXw7Xyc0dU,141
91
96
  aixtools/vault/vault.py,sha256=9dZLWdZQk9qN_Q9Djkofw9LUKnJqnrX5H0fGusVLBhA,6037
92
- aixtools-0.2.15.dist-info/METADATA,sha256=pMsq-5JO6i7tEE3rfFNwk2zD70H7qLLc8fzo1zObSTI,27230
93
- aixtools-0.2.15.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
94
- aixtools-0.2.15.dist-info/entry_points.txt,sha256=q8412TG4T0S8K0SKeWp2vkVPIDYQs0jNoHqcQ7qxOiA,155
95
- aixtools-0.2.15.dist-info/top_level.txt,sha256=wBn-rw9bCtxrR4AYEYgjilNCUVmKY0LWby9Zan2PRJM,9
96
- aixtools-0.2.15.dist-info/RECORD,,
97
+ aixtools-0.2.17.dist-info/METADATA,sha256=dCP4ss1Kz-AZWMQcmaHxR-HfqrtlSEVhIdZgff8ncvU,27958
98
+ aixtools-0.2.17.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
99
+ aixtools-0.2.17.dist-info/entry_points.txt,sha256=q8412TG4T0S8K0SKeWp2vkVPIDYQs0jNoHqcQ7qxOiA,155
100
+ aixtools-0.2.17.dist-info/top_level.txt,sha256=wBn-rw9bCtxrR4AYEYgjilNCUVmKY0LWby9Zan2PRJM,9
101
+ aixtools-0.2.17.dist-info/RECORD,,