aixtools 0.1.3__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.
- aixtools-0.1.3/.github/workflows/lint.yml → aixtools-0.1.5/.github/workflows/lint-and-test.yml +4 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/PKG-INFO +3 -1
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/_version.py +3 -3
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/a2a/app.py +1 -1
- aixtools-0.1.5/aixtools/a2a/google_sdk/__init__.py +0 -0
- aixtools-0.1.5/aixtools/a2a/google_sdk/card.py +27 -0
- aixtools-0.1.5/aixtools/a2a/google_sdk/pydantic_ai_adapter/agent_executor.py +199 -0
- aixtools-0.1.5/aixtools/a2a/google_sdk/pydantic_ai_adapter/storage.py +26 -0
- aixtools-0.1.5/aixtools/a2a/google_sdk/remote_agent_connection.py +88 -0
- aixtools-0.1.5/aixtools/a2a/google_sdk/utils.py +59 -0
- aixtools-0.1.5/aixtools/agents/prompt.py +97 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/context.py +5 -0
- aixtools-0.1.5/aixtools/google/client.py +25 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/logging/logging_config.py +45 -0
- aixtools-0.1.5/aixtools/mcp/client.py +274 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/mcp/faulty_mcp.py +7 -7
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/server/utils.py +3 -3
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/utils/config.py +6 -0
- aixtools-0.1.5/aixtools/utils/files.py +17 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/utils/utils.py +7 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools.egg-info/PKG-INFO +3 -1
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools.egg-info/SOURCES.txt +34 -3
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools.egg-info/requires.txt +2 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools.egg-info/top_level.txt +1 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/pyproject.toml +8 -1
- aixtools-0.1.5/scripts/test.sh +23 -0
- aixtools-0.1.5/tests/__init__.py +0 -0
- aixtools-0.1.5/tests/unit/__init__.py +0 -0
- aixtools-0.1.5/tests/unit/a2a/__init__.py +0 -0
- aixtools-0.1.5/tests/unit/a2a/google_sdk/__init__.py +0 -0
- aixtools-0.1.5/tests/unit/a2a/google_sdk/pydantic_ai_adapter/__init__.py +0 -0
- aixtools-0.1.5/tests/unit/a2a/google_sdk/pydantic_ai_adapter/test_agent_executor.py +188 -0
- aixtools-0.1.5/tests/unit/a2a/google_sdk/pydantic_ai_adapter/test_storage.py +156 -0
- aixtools-0.1.5/tests/unit/a2a/google_sdk/test_card.py +114 -0
- aixtools-0.1.5/tests/unit/a2a/google_sdk/test_remote_agent_connection.py +413 -0
- aixtools-0.1.5/tests/unit/a2a/google_sdk/test_utils.py +208 -0
- aixtools-0.1.5/tests/unit/agents/__init__.py +0 -0
- aixtools-0.1.5/tests/unit/agents/test_prompt.py +363 -0
- aixtools-0.1.5/tests/unit/google/__init__.py +1 -0
- aixtools-0.1.5/tests/unit/google/test_client.py +233 -0
- aixtools-0.1.5/tests/unit/mcp/__init__.py +0 -0
- aixtools-0.1.5/tests/unit/mcp/test_client.py +242 -0
- aixtools-0.1.5/tests/unit/server/__init__.py +0 -0
- aixtools-0.1.5/tests/unit/server/test_path.py +225 -0
- aixtools-0.1.5/tests/unit/server/test_utils.py +362 -0
- aixtools-0.1.5/tests/unit/utils/__init__.py +0 -0
- aixtools-0.1.5/tests/unit/utils/test_files.py +146 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/uv.lock +131 -0
- aixtools-0.1.3/aixtools/a2a/__init__.py +0 -5
- {aixtools-0.1.3 → aixtools-0.1.5}/.env_template +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/.github/workflows/build_and_publish_docker.yml +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/.github/workflows/release.yml +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/.gitignore +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/.python-version +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/.roo/rules/rules-mcp.md +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/.roo/rules/rules.md +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/.vscode/settings.json +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/README.md +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/.chainlit/config.toml +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/.chainlit/translations/bn.json +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/.chainlit/translations/en-US.json +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/.chainlit/translations/gu.json +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/.chainlit/translations/he-IL.json +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/.chainlit/translations/hi.json +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/.chainlit/translations/ja.json +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/.chainlit/translations/kn.json +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/.chainlit/translations/ml.json +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/.chainlit/translations/mr.json +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/.chainlit/translations/nl.json +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/.chainlit/translations/ta.json +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/.chainlit/translations/te.json +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/.chainlit/translations/zh-CN.json +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/__init__.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/a2a/utils.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/agents/__init__.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/agents/agent.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/agents/agent_batch.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/app.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/chainlit.md +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/db/__init__.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/db/database.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/db/vector_db.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/log_view/__init__.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/log_view/app.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/log_view/display.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/log_view/export.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/log_view/filters.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/log_view/log_utils.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/log_view/node_summary.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/logfilters/__init__.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/logfilters/context_filter.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/logging/__init__.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/logging/log_objects.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/logging/mcp_log_models.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/logging/mcp_logger.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/logging/model_patch_logging.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/logging/open_telemetry.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/mcp/__init__.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/mcp/example_client.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/mcp/example_server.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/mcp/fast_mcp_log.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/model_patch/model_patch.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/server/__init__.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/server/app_mounter.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/server/path.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/testing/__init__.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/testing/aix_test_model.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/testing/mock_tool.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/testing/model_patch_cache.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/tools/doctor/__init__.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/tools/doctor/tool_doctor.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/tools/doctor/tool_recommendation.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/utils/__init__.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/utils/chainlit/cl_agent_show.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/utils/chainlit/cl_utils.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/utils/config_util.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/utils/enum_with_description.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools/utils/persisted_dict.py +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools.egg-info/dependency_links.txt +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/aixtools.egg-info/entry_points.txt +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/docker/mcp-base/Dockerfile +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/notebooks/example_faulty_mcp_server.ipynb +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/notebooks/example_mcp_server_stdio.ipynb +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/notebooks/example_raw_mcp_client.ipynb +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/notebooks/example_tool_doctor.ipynb +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/scripts/config.sh +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/scripts/lint.sh +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/scripts/log_view.sh +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/scripts/run_example_mcp_server.sh +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/scripts/run_faulty_mcp_server.sh +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/scripts/run_server.sh +0 -0
- {aixtools-0.1.3 → aixtools-0.1.5}/setup.cfg +0 -0
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aixtools
|
|
3
|
-
Version: 0.1.
|
|
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.
|
|
32
|
-
__version_tuple__ = version_tuple = (0, 1,
|
|
31
|
+
__version__ = version = '0.1.5'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 1, 5)
|
|
33
33
|
|
|
34
|
-
__commit_id__ = commit_id = '
|
|
34
|
+
__commit_id__ = commit_id = 'g6b31b6684'
|
|
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:
|