aixtools 0.1.4__tar.gz → 0.1.5__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 (132) hide show
  1. aixtools-0.1.4/.github/workflows/lint.yml → aixtools-0.1.5/.github/workflows/lint-and-test.yml +4 -0
  2. {aixtools-0.1.4 → aixtools-0.1.5}/PKG-INFO +3 -1
  3. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/_version.py +3 -3
  4. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/a2a/app.py +1 -1
  5. aixtools-0.1.5/aixtools/a2a/google_sdk/__init__.py +0 -0
  6. aixtools-0.1.5/aixtools/a2a/google_sdk/card.py +27 -0
  7. aixtools-0.1.5/aixtools/a2a/google_sdk/pydantic_ai_adapter/agent_executor.py +199 -0
  8. aixtools-0.1.5/aixtools/a2a/google_sdk/pydantic_ai_adapter/storage.py +26 -0
  9. aixtools-0.1.5/aixtools/a2a/google_sdk/remote_agent_connection.py +88 -0
  10. aixtools-0.1.5/aixtools/a2a/google_sdk/utils.py +59 -0
  11. aixtools-0.1.5/aixtools/agents/prompt.py +97 -0
  12. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/context.py +5 -0
  13. aixtools-0.1.5/aixtools/google/client.py +25 -0
  14. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/logging/logging_config.py +45 -0
  15. aixtools-0.1.5/aixtools/mcp/client.py +274 -0
  16. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/server/utils.py +3 -3
  17. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/utils/config.py +6 -0
  18. aixtools-0.1.5/aixtools/utils/files.py +17 -0
  19. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/utils/utils.py +7 -0
  20. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools.egg-info/PKG-INFO +3 -1
  21. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools.egg-info/SOURCES.txt +34 -3
  22. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools.egg-info/requires.txt +2 -0
  23. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools.egg-info/top_level.txt +1 -0
  24. {aixtools-0.1.4 → aixtools-0.1.5}/pyproject.toml +8 -1
  25. aixtools-0.1.5/scripts/test.sh +23 -0
  26. aixtools-0.1.5/tests/__init__.py +0 -0
  27. aixtools-0.1.5/tests/unit/__init__.py +0 -0
  28. aixtools-0.1.5/tests/unit/a2a/__init__.py +0 -0
  29. aixtools-0.1.5/tests/unit/a2a/google_sdk/__init__.py +0 -0
  30. aixtools-0.1.5/tests/unit/a2a/google_sdk/pydantic_ai_adapter/__init__.py +0 -0
  31. aixtools-0.1.5/tests/unit/a2a/google_sdk/pydantic_ai_adapter/test_agent_executor.py +188 -0
  32. aixtools-0.1.5/tests/unit/a2a/google_sdk/pydantic_ai_adapter/test_storage.py +156 -0
  33. aixtools-0.1.5/tests/unit/a2a/google_sdk/test_card.py +114 -0
  34. aixtools-0.1.5/tests/unit/a2a/google_sdk/test_remote_agent_connection.py +413 -0
  35. aixtools-0.1.5/tests/unit/a2a/google_sdk/test_utils.py +208 -0
  36. aixtools-0.1.5/tests/unit/agents/__init__.py +0 -0
  37. aixtools-0.1.5/tests/unit/agents/test_prompt.py +363 -0
  38. aixtools-0.1.5/tests/unit/google/__init__.py +1 -0
  39. aixtools-0.1.5/tests/unit/google/test_client.py +233 -0
  40. aixtools-0.1.5/tests/unit/mcp/__init__.py +0 -0
  41. aixtools-0.1.5/tests/unit/mcp/test_client.py +242 -0
  42. aixtools-0.1.5/tests/unit/server/__init__.py +0 -0
  43. aixtools-0.1.5/tests/unit/server/test_path.py +225 -0
  44. aixtools-0.1.5/tests/unit/server/test_utils.py +362 -0
  45. aixtools-0.1.5/tests/unit/utils/__init__.py +0 -0
  46. aixtools-0.1.5/tests/unit/utils/test_files.py +146 -0
  47. {aixtools-0.1.4 → aixtools-0.1.5}/uv.lock +131 -0
  48. aixtools-0.1.4/aixtools/a2a/__init__.py +0 -5
  49. {aixtools-0.1.4 → aixtools-0.1.5}/.env_template +0 -0
  50. {aixtools-0.1.4 → aixtools-0.1.5}/.github/workflows/build_and_publish_docker.yml +0 -0
  51. {aixtools-0.1.4 → aixtools-0.1.5}/.github/workflows/release.yml +0 -0
  52. {aixtools-0.1.4 → aixtools-0.1.5}/.gitignore +0 -0
  53. {aixtools-0.1.4 → aixtools-0.1.5}/.python-version +0 -0
  54. {aixtools-0.1.4 → aixtools-0.1.5}/.roo/rules/rules-mcp.md +0 -0
  55. {aixtools-0.1.4 → aixtools-0.1.5}/.roo/rules/rules.md +0 -0
  56. {aixtools-0.1.4 → aixtools-0.1.5}/.vscode/settings.json +0 -0
  57. {aixtools-0.1.4 → aixtools-0.1.5}/README.md +0 -0
  58. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/.chainlit/config.toml +0 -0
  59. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/.chainlit/translations/bn.json +0 -0
  60. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/.chainlit/translations/en-US.json +0 -0
  61. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/.chainlit/translations/gu.json +0 -0
  62. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/.chainlit/translations/he-IL.json +0 -0
  63. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/.chainlit/translations/hi.json +0 -0
  64. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/.chainlit/translations/ja.json +0 -0
  65. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/.chainlit/translations/kn.json +0 -0
  66. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/.chainlit/translations/ml.json +0 -0
  67. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/.chainlit/translations/mr.json +0 -0
  68. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/.chainlit/translations/nl.json +0 -0
  69. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/.chainlit/translations/ta.json +0 -0
  70. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/.chainlit/translations/te.json +0 -0
  71. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/.chainlit/translations/zh-CN.json +0 -0
  72. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/__init__.py +0 -0
  73. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/a2a/utils.py +0 -0
  74. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/agents/__init__.py +0 -0
  75. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/agents/agent.py +0 -0
  76. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/agents/agent_batch.py +0 -0
  77. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/app.py +0 -0
  78. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/chainlit.md +0 -0
  79. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/db/__init__.py +0 -0
  80. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/db/database.py +0 -0
  81. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/db/vector_db.py +0 -0
  82. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/log_view/__init__.py +0 -0
  83. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/log_view/app.py +0 -0
  84. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/log_view/display.py +0 -0
  85. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/log_view/export.py +0 -0
  86. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/log_view/filters.py +0 -0
  87. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/log_view/log_utils.py +0 -0
  88. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/log_view/node_summary.py +0 -0
  89. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/logfilters/__init__.py +0 -0
  90. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/logfilters/context_filter.py +0 -0
  91. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/logging/__init__.py +0 -0
  92. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/logging/log_objects.py +0 -0
  93. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/logging/mcp_log_models.py +0 -0
  94. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/logging/mcp_logger.py +0 -0
  95. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/logging/model_patch_logging.py +0 -0
  96. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/logging/open_telemetry.py +0 -0
  97. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/mcp/__init__.py +0 -0
  98. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/mcp/example_client.py +0 -0
  99. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/mcp/example_server.py +0 -0
  100. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/mcp/fast_mcp_log.py +0 -0
  101. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/mcp/faulty_mcp.py +0 -0
  102. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/model_patch/model_patch.py +0 -0
  103. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/server/__init__.py +0 -0
  104. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/server/app_mounter.py +0 -0
  105. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/server/path.py +0 -0
  106. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/testing/__init__.py +0 -0
  107. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/testing/aix_test_model.py +0 -0
  108. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/testing/mock_tool.py +0 -0
  109. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/testing/model_patch_cache.py +0 -0
  110. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/tools/doctor/__init__.py +0 -0
  111. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/tools/doctor/tool_doctor.py +0 -0
  112. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/tools/doctor/tool_recommendation.py +0 -0
  113. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/utils/__init__.py +0 -0
  114. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/utils/chainlit/cl_agent_show.py +0 -0
  115. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/utils/chainlit/cl_utils.py +0 -0
  116. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/utils/config_util.py +0 -0
  117. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/utils/enum_with_description.py +0 -0
  118. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools/utils/persisted_dict.py +0 -0
  119. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools.egg-info/dependency_links.txt +0 -0
  120. {aixtools-0.1.4 → aixtools-0.1.5}/aixtools.egg-info/entry_points.txt +0 -0
  121. {aixtools-0.1.4 → aixtools-0.1.5}/docker/mcp-base/Dockerfile +0 -0
  122. {aixtools-0.1.4 → aixtools-0.1.5}/notebooks/example_faulty_mcp_server.ipynb +0 -0
  123. {aixtools-0.1.4 → aixtools-0.1.5}/notebooks/example_mcp_server_stdio.ipynb +0 -0
  124. {aixtools-0.1.4 → aixtools-0.1.5}/notebooks/example_raw_mcp_client.ipynb +0 -0
  125. {aixtools-0.1.4 → aixtools-0.1.5}/notebooks/example_tool_doctor.ipynb +0 -0
  126. {aixtools-0.1.4 → aixtools-0.1.5}/scripts/config.sh +0 -0
  127. {aixtools-0.1.4 → aixtools-0.1.5}/scripts/lint.sh +0 -0
  128. {aixtools-0.1.4 → aixtools-0.1.5}/scripts/log_view.sh +0 -0
  129. {aixtools-0.1.4 → aixtools-0.1.5}/scripts/run_example_mcp_server.sh +0 -0
  130. {aixtools-0.1.4 → aixtools-0.1.5}/scripts/run_faulty_mcp_server.sh +0 -0
  131. {aixtools-0.1.4 → aixtools-0.1.5}/scripts/run_server.sh +0 -0
  132. {aixtools-0.1.4 → aixtools-0.1.5}/setup.cfg +0 -0
@@ -35,3 +35,7 @@ jobs:
35
35
  - name: Run linting
36
36
  run: |
37
37
  ./scripts/lint.sh
38
+
39
+ - name: Test
40
+ run: |
41
+ ./scripts/test.sh
@@ -1,9 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aixtools
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: Tools for AI exploration and debugging
5
5
  Requires-Python: >=3.11.2
6
6
  Description-Content-Type: text/markdown
7
+ Requires-Dist: a2a-sdk>=0.3.1
8
+ Requires-Dist: cachebox>=5.0.1
7
9
  Requires-Dist: chainlit>=2.5.5
8
10
  Requires-Dist: colorlog>=6.9.0
9
11
  Requires-Dist: fasta2a>=0.5.0
@@ -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.1.4'
32
- __version_tuple__ = version_tuple = (0, 1, 4)
31
+ __version__ = version = '0.1.5'
32
+ __version_tuple__ = version_tuple = (0, 1, 5)
33
33
 
34
- __commit_id__ = commit_id = 'g2ba64c16e'
34
+ __commit_id__ = commit_id = 'g6b31b6684'
@@ -103,7 +103,7 @@ def agent_to_a2a(
103
103
  name=name,
104
104
  description=description,
105
105
  skills=skills,
106
- url=None,
106
+ url="",
107
107
  lifespan=partial(worker_lifespan, worker=worker, agent=agent),
108
108
  )
109
109
 
File without changes
@@ -0,0 +1,27 @@
1
+ import httpx
2
+ from a2a.client import A2ACardResolver
3
+ from a2a.types import AgentCard
4
+
5
+ from aixtools.logging.logging_config import get_logger
6
+
7
+ logger = get_logger(__name__)
8
+
9
+
10
+ async def get_agent_card(httpx_client: httpx.AsyncClient, agent_url: str) -> AgentCard:
11
+ resolver = A2ACardResolver(
12
+ httpx_client=httpx_client,
13
+ base_url=agent_url,
14
+ )
15
+
16
+ try:
17
+ _public_card = await resolver.get_agent_card() # Fetches from default public path
18
+ logger.info("Successfully fetched public agent card:")
19
+ logger.info(_public_card.model_dump_json(indent=2, exclude_none=True))
20
+ final_agent_card_to_use = _public_card
21
+ except Exception as e:
22
+ logger.error(f"Critical error fetching public agent card: {e}", exc_info=True)
23
+ raise RuntimeError("Failed to fetch the public agent card. Cannot continue.") from e
24
+
25
+ # Set the URL which is accessible from the container
26
+ final_agent_card_to_use.url = agent_url
27
+ return final_agent_card_to_use
@@ -0,0 +1,199 @@
1
+ from pathlib import Path
2
+
3
+ from a2a.server.agent_execution import AgentExecutor, RequestContext
4
+ from a2a.server.events import EventQueue
5
+ from a2a.types import (
6
+ Artifact,
7
+ FilePart,
8
+ FileWithUri,
9
+ Message,
10
+ Part,
11
+ TaskArtifactUpdateEvent,
12
+ TaskState,
13
+ TaskStatus,
14
+ TaskStatusUpdateEvent,
15
+ )
16
+ from a2a.utils import get_file_parts, get_message_text, new_agent_text_message, new_task
17
+ from pydantic import BaseModel
18
+ from pydantic_ai import Agent, BinaryContent
19
+
20
+ from aixtools.a2a.google_sdk.pydantic_ai_adapter.storage import InMemoryHistoryStorage
21
+ from aixtools.a2a.google_sdk.remote_agent_connection import is_in_terminal_state
22
+ from aixtools.a2a.google_sdk.utils import get_session_id_tuple
23
+ from aixtools.agents import get_agent
24
+ from aixtools.agents.prompt import build_user_input
25
+ from aixtools.context import SessionIdTuple
26
+ from aixtools.logging.logging_config import get_logger
27
+ from aixtools.mcp.client import get_configured_mcp_servers
28
+
29
+ logger = get_logger(__name__)
30
+
31
+
32
+ class AgentParameters(BaseModel):
33
+ system_prompt: str
34
+ mcp_servers: list[str]
35
+
36
+
37
+ class RunOutput(BaseModel):
38
+ is_task_failed: bool
39
+ is_task_in_progress: bool
40
+ is_input_required: bool
41
+ output: str
42
+ created_artifacts_paths: list[str]
43
+
44
+
45
+ def _task_failed_event(text: str, context_id: str | None, task_id: str | None) -> TaskStatusUpdateEvent:
46
+ """Creates a TaskStatusUpdateEvent indicating task failure."""
47
+ return TaskStatusUpdateEvent(
48
+ status=TaskStatus(
49
+ state=TaskState.failed, message=new_agent_text_message(text=text, context_id=context_id, task_id=task_id)
50
+ ),
51
+ final=True,
52
+ context_id=context_id,
53
+ task_id=task_id,
54
+ )
55
+
56
+
57
+ class PydanticAgentExecutor(AgentExecutor):
58
+ def __init__(self, agent_parameters: AgentParameters):
59
+ self._agent_parameters = agent_parameters
60
+ self.history_storage = InMemoryHistoryStorage()
61
+
62
+ def _convert_message_to_pydantic_parts(
63
+ self,
64
+ session_tuple: SessionIdTuple,
65
+ message: Message,
66
+ ) -> str | list[str | BinaryContent]:
67
+ """Convert A2A Message to a Pydantic AI agent input format"""
68
+ text_prompt = get_message_text(message)
69
+ file_parts = get_file_parts(message.parts)
70
+ if not file_parts:
71
+ return text_prompt
72
+ file_paths = [Path(part.uri) for part in file_parts if isinstance(part, FileWithUri)]
73
+
74
+ return build_user_input(session_tuple, text_prompt, file_paths)
75
+
76
+ async def execute(
77
+ self,
78
+ context: RequestContext,
79
+ event_queue: EventQueue,
80
+ ) -> None:
81
+ """
82
+ Execute the agent run.
83
+ Wraps pydantic ai agent execution with a2a protocol events
84
+ Args:
85
+ context (RequestContext): The request context containing the message and task information.
86
+ event_queue (EventQueue): The event queue to enqueue events.
87
+ """
88
+ session_tuple = get_session_id_tuple(context)
89
+ agent = self._build_agent(session_tuple)
90
+ if context.message is None:
91
+ raise ValueError("No message provided")
92
+
93
+ task = context.current_task
94
+ message = context.message
95
+ if not task:
96
+ task = new_task(message)
97
+ await event_queue.enqueue_event(task)
98
+
99
+ if is_in_terminal_state(task):
100
+ raise RuntimeError("Can not perform a task as it is in a terminal state: %s", task.status.state)
101
+
102
+ prompt = self._convert_message_to_pydantic_parts(session_tuple, message)
103
+ history_message = self.history_storage.get(task.id)
104
+
105
+ try:
106
+ result = await agent.run(
107
+ user_prompt=prompt,
108
+ message_history=history_message,
109
+ )
110
+ except Exception as e:
111
+ await event_queue.enqueue_event(
112
+ _task_failed_event(
113
+ text=f"Agent execution error: {e}",
114
+ context_id=context.context_id,
115
+ task_id=task.id,
116
+ )
117
+ )
118
+ return
119
+
120
+ self.history_storage.store(task.id, result.all_messages())
121
+
122
+ run_output: RunOutput = result.output
123
+ if run_output.is_task_failed:
124
+ await event_queue.enqueue_event(
125
+ _task_failed_event(
126
+ text=f"Task failed: {run_output.output}",
127
+ context_id=context.context_id,
128
+ task_id=task.id,
129
+ )
130
+ )
131
+ return
132
+
133
+ if run_output.is_input_required:
134
+ await event_queue.enqueue_event(
135
+ TaskStatusUpdateEvent(
136
+ status=TaskStatus(
137
+ state=TaskState.input_required,
138
+ message=new_agent_text_message(
139
+ text=run_output.output, context_id=context.context_id, task_id=task.id
140
+ ),
141
+ ),
142
+ final=False,
143
+ context_id=context.context_id,
144
+ task_id=task.id,
145
+ )
146
+ )
147
+ return
148
+
149
+ if run_output.is_task_in_progress:
150
+ logger.error("Task hasn't been completed: %s", run_output.output)
151
+ await event_queue.enqueue_event(
152
+ _task_failed_event(
153
+ text=f"Agent didn't manage complete the task: {run_output.output}",
154
+ context_id=context.context_id,
155
+ task_id=task.id,
156
+ )
157
+ )
158
+ return
159
+
160
+ for idx, artifact in enumerate(run_output.created_artifacts_paths):
161
+ image_file = FileWithUri(uri=str(artifact), name=f"image_{idx}")
162
+ await event_queue.enqueue_event(
163
+ TaskArtifactUpdateEvent(
164
+ append=False,
165
+ context_id=task.context_id,
166
+ task_id=task.id,
167
+ last_chunk=True,
168
+ artifact=Artifact(
169
+ artifact_id=f"image_{idx}",
170
+ parts=[Part(root=FilePart(file=image_file))],
171
+ ),
172
+ )
173
+ )
174
+ await event_queue.enqueue_event(
175
+ TaskStatusUpdateEvent(
176
+ status=TaskStatus(
177
+ state=TaskState.completed,
178
+ message=new_agent_text_message(
179
+ text=run_output.output, context_id=context.context_id, task_id=task.id
180
+ ),
181
+ ),
182
+ final=True,
183
+ context_id=context.context_id,
184
+ task_id=task.id,
185
+ )
186
+ )
187
+
188
+ async def cancel(self, ctx: RequestContext, event_queue: EventQueue) -> None:
189
+ """Cancel"""
190
+ raise Exception("cancel not supported")
191
+
192
+ def _build_agent(self, session_tuple: SessionIdTuple) -> Agent:
193
+ params = self._agent_parameters
194
+ mcp_servers = get_configured_mcp_servers(session_tuple, params.mcp_servers)
195
+ return get_agent(
196
+ system_prompt=params.system_prompt,
197
+ toolsets=mcp_servers,
198
+ output_type=RunOutput,
199
+ )
@@ -0,0 +1,26 @@
1
+ """Storage interface and in-memory implementation for Pydantic AI agent history."""
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+ from pydantic_ai.messages import ModelRequest, ModelResponse
6
+
7
+
8
+ class PydanticAiAgentHistoryStorage(ABC):
9
+ @abstractmethod
10
+ def get(self, task_id: str) -> list[ModelRequest | ModelResponse] | None:
11
+ pass
12
+
13
+ @abstractmethod
14
+ def store(self, task_id: str, messages: list[ModelRequest | ModelResponse]) -> None:
15
+ pass
16
+
17
+
18
+ class InMemoryHistoryStorage(PydanticAiAgentHistoryStorage):
19
+ def __init__(self):
20
+ self.storage: dict[str, list[ModelRequest | ModelResponse]] = {}
21
+
22
+ def get(self, task_id: str) -> list[ModelRequest | ModelResponse] | None:
23
+ return self.storage.get(task_id, None)
24
+
25
+ def store(self, task_id: str, messages: list[ModelRequest | ModelResponse]) -> None:
26
+ self.storage[task_id] = messages
@@ -0,0 +1,88 @@
1
+ import asyncio
2
+
3
+ from a2a.client import Client
4
+ from a2a.types import (
5
+ AgentCard,
6
+ Message,
7
+ Task,
8
+ TaskQueryParams,
9
+ TaskState,
10
+ )
11
+
12
+ from aixtools.logging.logging_config import get_logger
13
+
14
+ logger = get_logger(__name__)
15
+
16
+
17
+ def is_in_terminal_state(task: Task) -> bool:
18
+ return task.status.state in [
19
+ TaskState.completed,
20
+ TaskState.canceled,
21
+ TaskState.failed,
22
+ ]
23
+
24
+
25
+ def is_in_terminal_or_interrupted_state(task: Task) -> bool:
26
+ return task.status.state in [
27
+ TaskState.input_required,
28
+ TaskState.unknown,
29
+ ] or is_in_terminal_state(task)
30
+
31
+
32
+ class RemoteAgentConnection:
33
+ def __init__(self, card: AgentCard, client: Client):
34
+ self._client = client
35
+ self._card = card
36
+
37
+ def get_agent_card(self) -> AgentCard:
38
+ """
39
+ Returns the agent card associated with this connection.
40
+ """
41
+ return self._card
42
+
43
+ async def send_message(self, message: Message) -> Task | Message | None:
44
+ """
45
+ Sends a message to the remote agent and returns either a Task, a Message, or None.
46
+ """
47
+ last_task: Task | None = None
48
+ try:
49
+ async for event in self._client.send_message(message):
50
+ if isinstance(event, Message):
51
+ return event
52
+ if is_in_terminal_or_interrupted_state(event[0]):
53
+ return event[0]
54
+ last_task = event[0]
55
+ except Exception as e:
56
+ logger.error("Exception found in send_message: %s", str(e))
57
+ raise e
58
+ return last_task
59
+
60
+ async def send_message_with_polling(
61
+ self,
62
+ message: Message,
63
+ *,
64
+ sleep_time: float = 0.2,
65
+ max_iter=1000,
66
+ ) -> Task | Message:
67
+ """
68
+ Sends a message to the remote agent and polls for the task status at regular intervals.
69
+ If the task reaches a terminal state or is interrupted, it returns the task.
70
+ If the task does not complete within the maximum number of iterations, it raises an exception.
71
+ """
72
+ last_task = await self.send_message(message)
73
+ if not last_task:
74
+ raise ValueError("No task or message returned from send_message")
75
+ if isinstance(last_task, Message):
76
+ return last_task
77
+
78
+ if is_in_terminal_or_interrupted_state(last_task):
79
+ return last_task
80
+ task_id = last_task.id
81
+ for _ in range(max_iter):
82
+ await asyncio.sleep(sleep_time)
83
+ task = await self._client.get_task(TaskQueryParams(id=task_id))
84
+ if is_in_terminal_or_interrupted_state(task):
85
+ return task
86
+
87
+ timeout_seconds = max_iter * sleep_time
88
+ raise Exception(f"Task did not complete in {timeout_seconds} seconds") # pylint: disable=broad-exception-raised
@@ -0,0 +1,59 @@
1
+ import asyncio
2
+
3
+ import httpx
4
+ from a2a.client import A2ACardResolver, ClientConfig, ClientFactory
5
+ from a2a.server.agent_execution import RequestContext
6
+ from a2a.types import AgentCard
7
+ from a2a.utils import AGENT_CARD_WELL_KNOWN_PATH, PREV_AGENT_CARD_WELL_KNOWN_PATH
8
+
9
+ from aixtools.a2a.google_sdk.remote_agent_connection import RemoteAgentConnection
10
+ from aixtools.context import DEFAULT_SESSION_ID, DEFAULT_USER_ID, SessionIdTuple
11
+
12
+
13
+ class AgentCardLoadFailedError(Exception):
14
+ pass
15
+
16
+
17
+ class _AgentCardResolver:
18
+ def __init__(self, client: httpx.AsyncClient):
19
+ self._httpx_client = client
20
+ self._a2a_client_factory = ClientFactory(ClientConfig(httpx_client=self._httpx_client))
21
+ self.clients: dict[str, RemoteAgentConnection] = {}
22
+
23
+ def register_agent_card(self, card: AgentCard):
24
+ remote_connection = RemoteAgentConnection(card, self._a2a_client_factory.create(card))
25
+ self.clients[card.name] = remote_connection
26
+
27
+ async def retrieve_card(self, address: str):
28
+ for card_path in [AGENT_CARD_WELL_KNOWN_PATH, PREV_AGENT_CARD_WELL_KNOWN_PATH]:
29
+ try:
30
+ card_resolver = A2ACardResolver(self._httpx_client, address, card_path)
31
+ card = await card_resolver.get_agent_card()
32
+ card.url = address
33
+ self.register_agent_card(card)
34
+ return
35
+ except Exception as e:
36
+ print(f"Error retrieving agent card from {address} at path {card_path}: {e}")
37
+
38
+ raise AgentCardLoadFailedError(f"Failed to load agent card from {address}")
39
+
40
+ async def get_a2a_clients(self, agent_hosts: list[str]) -> dict[str, RemoteAgentConnection]:
41
+ async with asyncio.TaskGroup() as task_group:
42
+ for address in agent_hosts:
43
+ task_group.create_task(self.retrieve_card(address))
44
+
45
+ return self.clients
46
+
47
+
48
+ async def get_a2a_clients(ctx: SessionIdTuple, agent_hosts: list[str]) -> dict[str, RemoteAgentConnection]:
49
+ headers = {
50
+ "user-id": ctx[0],
51
+ "session-id": ctx[1],
52
+ }
53
+ httpx_client = httpx.AsyncClient(headers=headers, timeout=60.0)
54
+ return await _AgentCardResolver(httpx_client).get_a2a_clients(agent_hosts)
55
+
56
+
57
+ def get_session_id_tuple(context: RequestContext) -> SessionIdTuple:
58
+ headers = context.call_context.state.get("headers", {})
59
+ return headers.get("user-id", DEFAULT_USER_ID), headers.get("session-id", DEFAULT_SESSION_ID)
@@ -0,0 +1,97 @@
1
+ """Prompt building utilities for Pydantic AI agent, including file handling and context management."""
2
+
3
+ import mimetypes
4
+ from pathlib import Path, PurePosixPath
5
+
6
+ from pydantic_ai import BinaryContent
7
+
8
+ from aixtools.context import SessionIdTuple
9
+ from aixtools.server import container_to_host_path
10
+ from aixtools.utils.files import is_text_content
11
+
12
+ CLAUDE_MAX_FILE_SIZE_IN_CONTEXT = 4 * 1024 * 1024 # Claude limit 4.5 MB for PDF files
13
+ CLAUDE_IMAGE_MAX_FILE_SIZE_IN_CONTEXT = (
14
+ 5 * 1024 * 1024
15
+ ) # Claude limit 5 MB for images, to avoid large image files in context
16
+
17
+
18
+ def should_be_included_into_context(
19
+ file_content: BinaryContent | str | None,
20
+ file_size: int,
21
+ *,
22
+ max_img_size_bytes: int = CLAUDE_IMAGE_MAX_FILE_SIZE_IN_CONTEXT,
23
+ max_file_size_bytes: int = CLAUDE_MAX_FILE_SIZE_IN_CONTEXT,
24
+ ) -> bool:
25
+ """Decide whether a file content should be included into the model context based on its type and size."""
26
+ if not isinstance(file_content, BinaryContent):
27
+ return False
28
+
29
+ if file_content.media_type.startswith("text/"):
30
+ return False
31
+
32
+ # Exclude archive files as they're not supported by OpenAI models
33
+ archive_types = {
34
+ "application/zip",
35
+ "application/x-tar",
36
+ "application/gzip",
37
+ "application/x-gzip",
38
+ "application/x-rar-compressed",
39
+ "application/x-7z-compressed",
40
+ }
41
+ if file_content.media_type in archive_types:
42
+ return False
43
+
44
+ if file_content.is_image and file_size < max_img_size_bytes:
45
+ return True
46
+
47
+ return file_size < max_file_size_bytes
48
+
49
+
50
+ def file_to_binary_content(file_path: str | Path, mime_type: str = "") -> str | BinaryContent:
51
+ """
52
+ Read a file and return its content as either a UTF-8 string (for text files)
53
+ or BinaryContent (for binary files).
54
+ """
55
+ with open(file_path, "rb") as f:
56
+ data = f.read()
57
+
58
+ if not mime_type:
59
+ mime_type, _ = mimetypes.guess_type(file_path)
60
+ mime_type = mime_type or "application/octet-stream"
61
+
62
+ if is_text_content(data, mime_type):
63
+ return data.decode("utf-8")
64
+
65
+ return BinaryContent(data=data, media_type=mime_type)
66
+
67
+
68
+ def build_user_input(
69
+ session_tuple: SessionIdTuple,
70
+ user_text: str,
71
+ file_paths: list[Path],
72
+ ) -> str | list[str | BinaryContent]:
73
+ """Build user input for the Pydantic AI agent, including file attachments if provided."""
74
+ if not file_paths:
75
+ return user_text
76
+
77
+ attachment_info_lines = []
78
+ binary_attachments = []
79
+
80
+ for workspace_path in file_paths:
81
+ host_path = container_to_host_path(PurePosixPath(workspace_path), ctx=session_tuple)
82
+ file_size = host_path.stat().st_size
83
+ mime_type, _ = mimetypes.guess_type(host_path)
84
+ mime_type = mime_type or "application/octet-stream"
85
+
86
+ attachment_info = f"* {workspace_path.name} (file_size={file_size} bytes) (path in workspace: {workspace_path})"
87
+ binary_content = file_to_binary_content(host_path, mime_type)
88
+
89
+ if should_be_included_into_context(binary_content, file_size):
90
+ binary_attachments.append(binary_content)
91
+ attachment_info += f" -- provided to model context at index {len(binary_attachments) - 1}"
92
+
93
+ attachment_info_lines.append(attachment_info)
94
+
95
+ full_prompt = user_text + "\nAttachments:\n" + "\n".join(attachment_info_lines)
96
+
97
+ return [full_prompt] + binary_attachments
@@ -10,3 +10,8 @@ from contextvars import ContextVar
10
10
  # These can be populated by middleware or where they are initialized
11
11
  session_id_var: ContextVar[str | None] = ContextVar("session_id", default=None)
12
12
  user_id_var: ContextVar[str | None] = ContextVar("user_id", default=None)
13
+
14
+ DEFAULT_USER_ID = "default_user"
15
+ DEFAULT_SESSION_ID = "default_session"
16
+
17
+ SessionIdTuple = tuple[str, str]
@@ -0,0 +1,25 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ from google import genai
5
+
6
+ from aixtools.logging.logging_config import get_logger
7
+ from aixtools.utils.config import GOOGLE_CLOUD_LOCATION, GOOGLE_CLOUD_PROJECT, GOOGLE_GENAI_USE_VERTEXAI
8
+
9
+ logger = get_logger(__name__)
10
+
11
+
12
+ def get_genai_client(service_account_key_path: Path | None = None) -> genai.Client:
13
+ """Initialize and return a Google GenAI client using Vertex AI / Gemini Developer API."""
14
+ assert GOOGLE_CLOUD_PROJECT, "GOOGLE_CLOUD_PROJECT is not set"
15
+ assert GOOGLE_CLOUD_LOCATION, "GOOGLE_CLOUD_LOCATION is not set"
16
+ if service_account_key_path:
17
+ if not service_account_key_path.exists():
18
+ raise FileNotFoundError(f"Service account key file not found: {service_account_key_path}")
19
+ logger.info(f"✅ GCP Service Account Key File: {service_account_key_path}")
20
+ os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = str(service_account_key_path)
21
+ return genai.Client(
22
+ vertexai=GOOGLE_GENAI_USE_VERTEXAI,
23
+ project=GOOGLE_CLOUD_PROJECT,
24
+ location=GOOGLE_CLOUD_LOCATION,
25
+ )
@@ -6,6 +6,7 @@ import json
6
6
  import logging
7
7
  import logging.config
8
8
  import os
9
+ import sys
9
10
  import time
10
11
  from pathlib import Path
11
12
 
@@ -72,7 +73,51 @@ def configure_logging():
72
73
  2. logging.yaml in the current working directory.
73
74
  3. logging.json in the current working directory.
74
75
  4. Hardcoded default configuration.
76
+
77
+ Special handling for pytest: If running under pytest without explicit
78
+ log flags, console logging is suppressed to avoid interfering with
79
+ pytest's own log capture mechanism.
75
80
  """
81
+ # Detect if running under pytest and suppress console logging unless explicitly requested
82
+ is_pytest = "pytest" in sys.modules or "pytest" in sys.argv[0] if sys.argv else False
83
+
84
+ # Check for live log flags - handle both separate and combined flags
85
+ wants_live_logs = False
86
+ if sys.argv:
87
+ for arg in sys.argv:
88
+ # Check for explicit --log-cli
89
+ if arg == "--log-cli":
90
+ wants_live_logs = True
91
+ break
92
+ # Check for -s either standalone or combined with other flags (like -vsk, -vs, etc.)
93
+ if arg.startswith("-") and not arg.startswith("--") and "s" in arg:
94
+ wants_live_logs = True
95
+ break
96
+
97
+ if is_pytest and not wants_live_logs:
98
+ # Use a minimal configuration that doesn't output to console during pytest
99
+ pytest_config = {
100
+ "version": 1,
101
+ "disable_existing_loggers": False,
102
+ "formatters": {
103
+ "simple": {
104
+ "format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
105
+ }
106
+ },
107
+ "handlers": {
108
+ # Only use NullHandler to suppress console output but allow pytest log capture
109
+ "null": {
110
+ "class": "logging.NullHandler",
111
+ }
112
+ },
113
+ "root": {
114
+ "handlers": ["null"],
115
+ "level": "INFO",
116
+ },
117
+ }
118
+ logging.config.dictConfig(pytest_config)
119
+ return
120
+
76
121
  config_path_str = os.environ.get("LOGGING_CONFIG_PATH")
77
122
 
78
123
  if config_path_str: