quraite 0.0.1__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.
- quraite/__init__.py +3 -0
- quraite/adapters/__init__.py +134 -0
- quraite/adapters/agno_adapter.py +159 -0
- quraite/adapters/base.py +123 -0
- quraite/adapters/bedrock_agents_adapter.py +343 -0
- quraite/adapters/flowise_adapter.py +275 -0
- quraite/adapters/google_adk_adapter.py +209 -0
- quraite/adapters/http_adapter.py +239 -0
- quraite/adapters/langflow_adapter.py +192 -0
- quraite/adapters/langgraph_adapter.py +304 -0
- quraite/adapters/langgraph_server_adapter.py +252 -0
- quraite/adapters/n8n_adapter.py +220 -0
- quraite/adapters/openai_agents_adapter.py +269 -0
- quraite/adapters/pydantic_ai_adapter.py +312 -0
- quraite/adapters/smolagents_adapter.py +152 -0
- quraite/logger.py +62 -0
- quraite/schema/__init__.py +0 -0
- quraite/schema/message.py +54 -0
- quraite/schema/response.py +16 -0
- quraite/serve/__init__.py +1 -0
- quraite/serve/cloudflared.py +210 -0
- quraite/serve/local_agent.py +360 -0
- quraite/traces/traces_adk_openinference.json +379 -0
- quraite/traces/traces_agno_multi_agent.json +669 -0
- quraite/traces/traces_agno_openinference.json +321 -0
- quraite/traces/traces_crewai_openinference.json +155 -0
- quraite/traces/traces_langgraph_openinference.json +349 -0
- quraite/traces/traces_langgraph_openinference_multi_agent.json +2705 -0
- quraite/traces/traces_langgraph_traceloop.json +510 -0
- quraite/traces/traces_openai_agents_multi_agent_1.json +402 -0
- quraite/traces/traces_openai_agents_openinference.json +341 -0
- quraite/traces/traces_pydantic_openinference.json +286 -0
- quraite/traces/traces_pydantic_openinference_multi_agent_1.json +399 -0
- quraite/traces/traces_pydantic_openinference_multi_agent_2.json +398 -0
- quraite/traces/traces_smol_agents_openinference.json +397 -0
- quraite/traces/traces_smol_agents_tool_calling_openinference.json +704 -0
- quraite/tracing/__init__.py +24 -0
- quraite/tracing/constants.py +16 -0
- quraite/tracing/span_exporter.py +115 -0
- quraite/tracing/span_processor.py +49 -0
- quraite/tracing/tool_extractors.py +290 -0
- quraite/tracing/trace.py +494 -0
- quraite/tracing/types.py +179 -0
- quraite/tracing/utils.py +170 -0
- quraite/utils/__init__.py +0 -0
- quraite/utils/json_utils.py +269 -0
- quraite-0.0.1.dist-info/METADATA +44 -0
- quraite-0.0.1.dist-info/RECORD +49 -0
- quraite-0.0.1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from typing import List, Union
|
|
3
|
+
|
|
4
|
+
from google.adk.agents import Agent
|
|
5
|
+
from google.adk.apps.app import App
|
|
6
|
+
from google.adk.errors.already_exists_error import AlreadyExistsError
|
|
7
|
+
from google.adk.runners import Runner
|
|
8
|
+
from google.adk.sessions import BaseSessionService, InMemorySessionService
|
|
9
|
+
from google.genai import types
|
|
10
|
+
from opentelemetry.trace import TracerProvider
|
|
11
|
+
|
|
12
|
+
from quraite.adapters.base import BaseAdapter
|
|
13
|
+
from quraite.logger import get_logger
|
|
14
|
+
from quraite.schema.message import AgentMessage
|
|
15
|
+
from quraite.schema.response import AgentInvocationResponse
|
|
16
|
+
from quraite.tracing.constants import QURAITE_ADAPTER_TRACE_PREFIX, Framework
|
|
17
|
+
from quraite.tracing.trace import AgentSpan, AgentTrace
|
|
18
|
+
|
|
19
|
+
logger = get_logger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class GoogleADKAdapter(BaseAdapter):
|
|
23
|
+
"""
|
|
24
|
+
Google ADK adapter wrapper that converts any Google ADK agent
|
|
25
|
+
to a standardized callable interface (ainvoke) with tracing support.
|
|
26
|
+
|
|
27
|
+
This class wraps any Google ADK Agent and provides:
|
|
28
|
+
- Asynchronous invocation via ainvoke()
|
|
29
|
+
- OpenTelemetry tracing integration
|
|
30
|
+
- Session management for multi-turn conversations
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
agent: Agent,
|
|
36
|
+
agent_name: str = "Google ADK Agent",
|
|
37
|
+
tracer_provider: TracerProvider = None,
|
|
38
|
+
app_name: str = "google_adk_agent",
|
|
39
|
+
user_id: str = str(uuid.uuid4()),
|
|
40
|
+
session_service: BaseSessionService = InMemorySessionService(),
|
|
41
|
+
):
|
|
42
|
+
"""
|
|
43
|
+
Initialize with a pre-configured Google ADK agent
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
agent: A Google ADK Agent instance
|
|
47
|
+
app_name: Application name for ADK runner
|
|
48
|
+
agent_name: Name of the agent for trajectory metadata
|
|
49
|
+
tracer_provider: TracerProvider for tracing (required)
|
|
50
|
+
"""
|
|
51
|
+
logger.debug(
|
|
52
|
+
"Initializing GoogleADKAdapter (agent_name=%s, app_name=%s)",
|
|
53
|
+
agent_name,
|
|
54
|
+
app_name,
|
|
55
|
+
)
|
|
56
|
+
self._init_tracing(tracer_provider, required=True)
|
|
57
|
+
|
|
58
|
+
self.agent: Agent = agent
|
|
59
|
+
self.app_name = app_name
|
|
60
|
+
self.agent_name = agent_name
|
|
61
|
+
self.session_service = session_service
|
|
62
|
+
self.user_id = user_id
|
|
63
|
+
self.app = App(
|
|
64
|
+
name=app_name,
|
|
65
|
+
root_agent=agent,
|
|
66
|
+
)
|
|
67
|
+
self.runner = Runner(
|
|
68
|
+
app=self.app,
|
|
69
|
+
session_service=session_service,
|
|
70
|
+
)
|
|
71
|
+
logger.info("GoogleADKAdapter initialized successfully")
|
|
72
|
+
|
|
73
|
+
def _prepare_input(self, input: List[AgentMessage]) -> str:
|
|
74
|
+
"""
|
|
75
|
+
Prepare input for Google ADK agent from List[Message].
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
input: List[Message] containing user_message
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
str: User message text
|
|
82
|
+
"""
|
|
83
|
+
logger.debug("Preparing Google ADK input from %d messages", len(input))
|
|
84
|
+
if not input or input[-1].role != "user":
|
|
85
|
+
logger.error("Google ADK input missing user message")
|
|
86
|
+
raise ValueError("No user message found in the input")
|
|
87
|
+
|
|
88
|
+
last_user_message = input[-1]
|
|
89
|
+
# Check if content list is not empty and has text
|
|
90
|
+
if not last_user_message.content:
|
|
91
|
+
logger.error("Google ADK user message missing content")
|
|
92
|
+
raise ValueError("User message has no content")
|
|
93
|
+
|
|
94
|
+
# Find the first text content item
|
|
95
|
+
text_content = None
|
|
96
|
+
for content_item in last_user_message.content:
|
|
97
|
+
if content_item.type == "text" and content_item.text:
|
|
98
|
+
text_content = content_item.text
|
|
99
|
+
break
|
|
100
|
+
|
|
101
|
+
if not text_content:
|
|
102
|
+
logger.error("Google ADK user message missing text content")
|
|
103
|
+
raise ValueError("No text content found in user message")
|
|
104
|
+
|
|
105
|
+
logger.debug("Prepared Google ADK input (text_length=%d)", len(text_content))
|
|
106
|
+
return text_content
|
|
107
|
+
|
|
108
|
+
async def ainvoke(
|
|
109
|
+
self,
|
|
110
|
+
input: List[AgentMessage],
|
|
111
|
+
session_id: Union[str, None] = None,
|
|
112
|
+
) -> AgentInvocationResponse:
|
|
113
|
+
"""
|
|
114
|
+
Asynchronous invocation method - invokes the Google ADK agent with tracing
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
input: List[AgentMessage] containing user_message
|
|
118
|
+
session_id: Optional conversation ID for maintaining context
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
AgentInvocationResponse - response containing agent trace, trajectory, and final response.
|
|
122
|
+
"""
|
|
123
|
+
logger.info(
|
|
124
|
+
"Google ADK ainvoke called (session_id=%s, input_messages=%d)",
|
|
125
|
+
session_id,
|
|
126
|
+
len(input),
|
|
127
|
+
)
|
|
128
|
+
agent_input = self._prepare_input(input)
|
|
129
|
+
session_id = session_id or str(uuid.uuid4())
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
return await self._ainvoke_with_tracing(agent_input, session_id)
|
|
133
|
+
|
|
134
|
+
except Exception as e:
|
|
135
|
+
logger.exception("Error invoking Google ADK agent")
|
|
136
|
+
raise RuntimeError(f"Error invoking Google ADK agent: {e}") from e
|
|
137
|
+
|
|
138
|
+
async def _ainvoke_with_tracing(
|
|
139
|
+
self,
|
|
140
|
+
agent_input: str,
|
|
141
|
+
session_id: str,
|
|
142
|
+
) -> AgentInvocationResponse:
|
|
143
|
+
"""Execute ainvoke with tracing enabled."""
|
|
144
|
+
adapter_trace_id = f"{QURAITE_ADAPTER_TRACE_PREFIX}-{uuid.uuid4()}"
|
|
145
|
+
logger.debug(
|
|
146
|
+
"Starting Google ADK traced invocation (trace_id=%s, session_id=%s)",
|
|
147
|
+
adapter_trace_id,
|
|
148
|
+
session_id,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
with self.tracer.start_as_current_span(name=adapter_trace_id):
|
|
152
|
+
# Create session if it doesn't exist
|
|
153
|
+
try:
|
|
154
|
+
await self.session_service.create_session(
|
|
155
|
+
app_name=self.app_name,
|
|
156
|
+
user_id=self.user_id,
|
|
157
|
+
session_id=session_id,
|
|
158
|
+
)
|
|
159
|
+
except AlreadyExistsError:
|
|
160
|
+
logger.info("Session already exists: %s", session_id)
|
|
161
|
+
except Exception as e:
|
|
162
|
+
logger.exception("Error creating Google ADK session")
|
|
163
|
+
raise RuntimeError(f"Error creating session: {e}") from e
|
|
164
|
+
|
|
165
|
+
# Create content for ADK
|
|
166
|
+
content = types.Content(
|
|
167
|
+
role="user",
|
|
168
|
+
parts=[types.Part(text=agent_input)],
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Run async and consume events
|
|
172
|
+
events = self.runner.run_async(
|
|
173
|
+
new_message=content,
|
|
174
|
+
user_id=self.user_id,
|
|
175
|
+
session_id=session_id,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Consume all events (tracing captures everything)
|
|
179
|
+
async for event in events:
|
|
180
|
+
pass # Just consume events, tracing handles capture
|
|
181
|
+
|
|
182
|
+
# Get trace spans
|
|
183
|
+
trace_readable_spans = self.quraite_span_exporter.get_trace_by_testcase(
|
|
184
|
+
adapter_trace_id
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if trace_readable_spans:
|
|
188
|
+
agent_trace = AgentTrace(
|
|
189
|
+
spans=[
|
|
190
|
+
AgentSpan.from_readable_oi_span(span)
|
|
191
|
+
for span in trace_readable_spans
|
|
192
|
+
],
|
|
193
|
+
)
|
|
194
|
+
logger.info(
|
|
195
|
+
"Google ADK trace collected %d spans for trace_id=%s",
|
|
196
|
+
len(trace_readable_spans),
|
|
197
|
+
adapter_trace_id,
|
|
198
|
+
)
|
|
199
|
+
else:
|
|
200
|
+
logger.warning(
|
|
201
|
+
"No spans exported for Google ADK trace_id=%s", adapter_trace_id
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
return AgentInvocationResponse(
|
|
205
|
+
agent_trace=agent_trace,
|
|
206
|
+
agent_trajectory=agent_trace.to_agent_trajectory(
|
|
207
|
+
framework=Framework.GOOGLE_ADK
|
|
208
|
+
),
|
|
209
|
+
)
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP Adapter - Connects to remote agent servers via HTTP.
|
|
3
|
+
|
|
4
|
+
This module provides a BaseAdapter implementation that calls remote agent
|
|
5
|
+
servers via HTTP endpoints, enabling distributed agent evaluation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, Dict, List, Optional, Union
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from quraite.adapters.base import BaseAdapter
|
|
13
|
+
from quraite.logger import get_logger
|
|
14
|
+
from quraite.schema.message import AgentMessage
|
|
15
|
+
from quraite.schema.response import AgentInvocationResponse
|
|
16
|
+
|
|
17
|
+
logger = get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HttpAdapter(BaseAdapter):
|
|
21
|
+
"""
|
|
22
|
+
HTTP adapter client that communicates with agent servers via HTTP.
|
|
23
|
+
|
|
24
|
+
This class implements the BaseAdapter interface and forwards adapter
|
|
25
|
+
requests to a HTTP agent server, handling serialization, network errors,
|
|
26
|
+
and retries.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
url: The full URL of the remote agent endpoint (e.g., "http://localhost:8000/v1/agents/completions")
|
|
30
|
+
headers: Optional dictionary of HTTP headers to include in requests
|
|
31
|
+
timeout: Request timeout in seconds (default: 60)
|
|
32
|
+
max_retries: Maximum number of retry attempts (default: 3)
|
|
33
|
+
retry_delay: Initial retry delay in seconds (default: 1)
|
|
34
|
+
|
|
35
|
+
Example:
|
|
36
|
+
```python
|
|
37
|
+
remote_agent = HttpAdapter(
|
|
38
|
+
url="http://localhost:8000/v1/agents/completions",
|
|
39
|
+
headers={"Authorization": "Bearer secret_key"}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
result = remote_agent.ainvoke(
|
|
43
|
+
input=[UserMessage(...)],
|
|
44
|
+
session_id="conv_123"
|
|
45
|
+
)
|
|
46
|
+
```
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
url: str,
|
|
52
|
+
headers: Optional[Dict[str, str]] = None,
|
|
53
|
+
timeout: float = 60.0,
|
|
54
|
+
max_retries: int = 3,
|
|
55
|
+
retry_delay: float = 1.0,
|
|
56
|
+
):
|
|
57
|
+
"""Initialize the HTTP adapter client."""
|
|
58
|
+
self.url = url.rstrip("/")
|
|
59
|
+
self.headers = headers or {}
|
|
60
|
+
self.timeout = timeout
|
|
61
|
+
self.max_retries = max_retries
|
|
62
|
+
self.retry_delay = retry_delay
|
|
63
|
+
|
|
64
|
+
# Create HTTP client with user-provided headers
|
|
65
|
+
# Always include Content-Type if not provided
|
|
66
|
+
client_headers = {"Content-Type": "application/json"}
|
|
67
|
+
client_headers.update(self.headers)
|
|
68
|
+
|
|
69
|
+
self.async_client = httpx.AsyncClient(
|
|
70
|
+
headers=client_headers,
|
|
71
|
+
timeout=self.timeout,
|
|
72
|
+
)
|
|
73
|
+
logger.info(
|
|
74
|
+
"HttpAdapter initialized (url=%s, timeout=%s, max_retries=%s)",
|
|
75
|
+
self.url,
|
|
76
|
+
self.timeout,
|
|
77
|
+
self.max_retries,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def _serialize_request(
|
|
81
|
+
self,
|
|
82
|
+
input: List[AgentMessage],
|
|
83
|
+
session_id: Union[str, None],
|
|
84
|
+
) -> Dict[str, Any]:
|
|
85
|
+
"""
|
|
86
|
+
Serialize invocation request to JSON-compatible dict.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
input: List[AgentMessage] containing user_message
|
|
90
|
+
session_id: Optional conversation ID for maintaining context
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Dictionary ready for JSON serialization
|
|
94
|
+
"""
|
|
95
|
+
logger.debug(
|
|
96
|
+
"Serializing HTTP request (messages=%d, session_id=%s)",
|
|
97
|
+
len(input),
|
|
98
|
+
session_id,
|
|
99
|
+
)
|
|
100
|
+
return {
|
|
101
|
+
"input": [msg.model_dump(mode="json") for msg in input],
|
|
102
|
+
"session_id": session_id,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async def _make_request_with_retry_async(
|
|
106
|
+
self,
|
|
107
|
+
method: str,
|
|
108
|
+
url: str,
|
|
109
|
+
payload: Dict[str, Any],
|
|
110
|
+
) -> Dict[str, Any]:
|
|
111
|
+
"""
|
|
112
|
+
Make HTTP request with retry logic (asynchronous).
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
method: HTTP method (POST)
|
|
116
|
+
url: Full URL of the endpoint
|
|
117
|
+
payload: Request payload
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Response data as dictionary
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
httpx.HTTPError: If all retries fail
|
|
124
|
+
ValueError: If response format is invalid
|
|
125
|
+
"""
|
|
126
|
+
last_exception = None
|
|
127
|
+
|
|
128
|
+
for attempt in range(self.max_retries):
|
|
129
|
+
try:
|
|
130
|
+
response = await self.async_client.request(
|
|
131
|
+
method=method,
|
|
132
|
+
url=url,
|
|
133
|
+
json=payload,
|
|
134
|
+
)
|
|
135
|
+
response.raise_for_status()
|
|
136
|
+
logger.info(
|
|
137
|
+
"HTTP request succeeded (status=%s, attempt=%d)",
|
|
138
|
+
response.status_code,
|
|
139
|
+
attempt + 1,
|
|
140
|
+
)
|
|
141
|
+
return response.json()
|
|
142
|
+
|
|
143
|
+
except httpx.HTTPStatusError as e:
|
|
144
|
+
# Don't retry on 4xx errors (client errors)
|
|
145
|
+
if 400 <= e.response.status_code < 500:
|
|
146
|
+
error_detail = e.response.text
|
|
147
|
+
logger.error(
|
|
148
|
+
"HTTP adapter received 4xx response (status=%s, detail=%s)",
|
|
149
|
+
e.response.status_code,
|
|
150
|
+
error_detail,
|
|
151
|
+
)
|
|
152
|
+
raise ValueError(
|
|
153
|
+
f"Agent server error ({e.response.status_code}): {error_detail}"
|
|
154
|
+
) from e
|
|
155
|
+
|
|
156
|
+
# Retry on 5xx errors
|
|
157
|
+
last_exception = e
|
|
158
|
+
if attempt < self.max_retries - 1:
|
|
159
|
+
delay = self.retry_delay * (2**attempt)
|
|
160
|
+
logger.warning(
|
|
161
|
+
"HTTP adapter retrying after server error (status=%s, retry_in=%.2fs)",
|
|
162
|
+
e.response.status_code,
|
|
163
|
+
delay,
|
|
164
|
+
)
|
|
165
|
+
await self._async_sleep(delay)
|
|
166
|
+
|
|
167
|
+
except (httpx.ConnectError, httpx.TimeoutException) as e:
|
|
168
|
+
# Retry on network errors
|
|
169
|
+
last_exception = e
|
|
170
|
+
if attempt < self.max_retries - 1:
|
|
171
|
+
delay = self.retry_delay * (2**attempt)
|
|
172
|
+
logger.warning(
|
|
173
|
+
"HTTP adapter retrying after network error (retry_in=%.2fs)",
|
|
174
|
+
delay,
|
|
175
|
+
)
|
|
176
|
+
await self._async_sleep(delay)
|
|
177
|
+
|
|
178
|
+
# All retries failed
|
|
179
|
+
logger.exception(
|
|
180
|
+
"HTTP adapter failed after %d attempts (url=%s)",
|
|
181
|
+
self.max_retries,
|
|
182
|
+
self.url,
|
|
183
|
+
)
|
|
184
|
+
raise RuntimeError(
|
|
185
|
+
f"Failed to connect to agent server at {self.url} after "
|
|
186
|
+
f"{self.max_retries} attempts. Last error: {last_exception}"
|
|
187
|
+
) from last_exception
|
|
188
|
+
|
|
189
|
+
@staticmethod
|
|
190
|
+
async def _async_sleep(seconds: float):
|
|
191
|
+
"""Helper for async sleep."""
|
|
192
|
+
import asyncio
|
|
193
|
+
|
|
194
|
+
await asyncio.sleep(seconds)
|
|
195
|
+
|
|
196
|
+
async def ainvoke(
|
|
197
|
+
self,
|
|
198
|
+
input: List[AgentMessage],
|
|
199
|
+
session_id: Union[str, None],
|
|
200
|
+
) -> AgentInvocationResponse:
|
|
201
|
+
"""
|
|
202
|
+
Asynchronously invoke the HTTP agent.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
input: List[AgentMessage] containing user_message
|
|
206
|
+
session_id: Optional conversation ID for maintaining context
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
AgentInvocationResponse: Response containing agent trace, trajectory, and final response.
|
|
210
|
+
"""
|
|
211
|
+
logger.info(
|
|
212
|
+
"HTTP ainvoke called (session_id=%s, input_messages=%d)",
|
|
213
|
+
session_id,
|
|
214
|
+
len(input),
|
|
215
|
+
)
|
|
216
|
+
payload = self._serialize_request(input, session_id)
|
|
217
|
+
response_data = await self._make_request_with_retry_async(
|
|
218
|
+
method="POST", url=self.url, payload=payload
|
|
219
|
+
)
|
|
220
|
+
logger.debug(
|
|
221
|
+
"HTTP adapter received response keys: %s", list(response_data.keys())
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
return AgentInvocationResponse.model_validate(
|
|
225
|
+
response_data.get("agent_response", {})
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
async def aclose(self):
|
|
229
|
+
"""Close async HTTP client."""
|
|
230
|
+
await self.async_client.aclose()
|
|
231
|
+
logger.debug("HTTP adapter client closed")
|
|
232
|
+
|
|
233
|
+
async def __aenter__(self):
|
|
234
|
+
"""Async context manager entry."""
|
|
235
|
+
return self
|
|
236
|
+
|
|
237
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
238
|
+
"""Async context manager exit."""
|
|
239
|
+
await self.aclose()
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import uuid
|
|
4
|
+
from typing import Any, Dict, List, Union
|
|
5
|
+
|
|
6
|
+
import aiohttp
|
|
7
|
+
|
|
8
|
+
from quraite.adapters.base import BaseAdapter
|
|
9
|
+
from quraite.logger import get_logger
|
|
10
|
+
from quraite.schema.message import (
|
|
11
|
+
AgentMessage,
|
|
12
|
+
AssistantMessage,
|
|
13
|
+
MessageContentText,
|
|
14
|
+
ToolCall,
|
|
15
|
+
ToolMessage,
|
|
16
|
+
)
|
|
17
|
+
from quraite.schema.response import AgentInvocationResponse
|
|
18
|
+
|
|
19
|
+
logger = get_logger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LangflowAdapter(BaseAdapter):
|
|
23
|
+
def __init__(self, api_url: str, x_api_key: str, timeout: int = 60):
|
|
24
|
+
self.api_url = api_url
|
|
25
|
+
self.x_api_key = x_api_key
|
|
26
|
+
self.headers = {"Content-Type": "application/json", "x-api-key": self.x_api_key}
|
|
27
|
+
self.timeout = timeout
|
|
28
|
+
logger.info(
|
|
29
|
+
"LangflowAdapter initialized (api_url=%s, timeout=%s)",
|
|
30
|
+
self.api_url,
|
|
31
|
+
timeout,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def _convert_api_output_to_messages(
|
|
35
|
+
self,
|
|
36
|
+
response: Dict[str, Any],
|
|
37
|
+
) -> List[AgentMessage]:
|
|
38
|
+
logger.debug(
|
|
39
|
+
"Converting Langflow response (root_keys=%s)",
|
|
40
|
+
list(response.keys()),
|
|
41
|
+
)
|
|
42
|
+
content_blocks = response["outputs"][0]["outputs"][0]["results"]["message"][
|
|
43
|
+
"content_blocks"
|
|
44
|
+
]
|
|
45
|
+
contents = content_blocks[0]["contents"]
|
|
46
|
+
|
|
47
|
+
# Assume everything sequential.
|
|
48
|
+
ai_trajectory: List[AgentMessage] = []
|
|
49
|
+
for step in contents:
|
|
50
|
+
if step["type"] == "text":
|
|
51
|
+
if step["header"]["title"] == "Input":
|
|
52
|
+
continue
|
|
53
|
+
else:
|
|
54
|
+
ai_trajectory.append(
|
|
55
|
+
AssistantMessage(
|
|
56
|
+
content=[
|
|
57
|
+
MessageContentText(type="text", text=step["text"])
|
|
58
|
+
],
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
elif step["type"] == "tool_use":
|
|
62
|
+
tool_id = str(uuid.uuid4())
|
|
63
|
+
tool_input = step.get("tool_input", {})
|
|
64
|
+
if not isinstance(tool_input, dict):
|
|
65
|
+
tool_input = {"value": tool_input}
|
|
66
|
+
|
|
67
|
+
# Create AssistantMessage with tool call
|
|
68
|
+
ai_trajectory.append(
|
|
69
|
+
AssistantMessage(
|
|
70
|
+
tool_calls=[
|
|
71
|
+
ToolCall(
|
|
72
|
+
id=tool_id,
|
|
73
|
+
name=step["name"],
|
|
74
|
+
arguments=tool_input,
|
|
75
|
+
)
|
|
76
|
+
],
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
# Create ToolMessage with tool result
|
|
80
|
+
tool_output = step.get("output", "")
|
|
81
|
+
ai_trajectory.append(
|
|
82
|
+
ToolMessage(
|
|
83
|
+
tool_name=step["name"],
|
|
84
|
+
tool_call_id=tool_id,
|
|
85
|
+
content=[
|
|
86
|
+
MessageContentText(type="text", text=str(tool_output))
|
|
87
|
+
],
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
logger.info(
|
|
92
|
+
"Converted Langflow response into %d trajectory messages",
|
|
93
|
+
len(ai_trajectory),
|
|
94
|
+
)
|
|
95
|
+
return ai_trajectory
|
|
96
|
+
|
|
97
|
+
def _prepare_input(self, input: List[AgentMessage]) -> str:
|
|
98
|
+
logger.debug("Preparing Langflow input from %d messages", len(input))
|
|
99
|
+
if not input or input[-1].role != "user":
|
|
100
|
+
logger.error("Langflow input missing user message")
|
|
101
|
+
raise ValueError("No user message found in the input")
|
|
102
|
+
|
|
103
|
+
last_user_message = input[-1]
|
|
104
|
+
if not last_user_message.content:
|
|
105
|
+
logger.error("Langflow input user message missing content")
|
|
106
|
+
raise ValueError("User message has no content")
|
|
107
|
+
|
|
108
|
+
text_content = None
|
|
109
|
+
for content_item in last_user_message.content:
|
|
110
|
+
if content_item.type == "text" and content_item.text:
|
|
111
|
+
text_content = content_item.text
|
|
112
|
+
break
|
|
113
|
+
|
|
114
|
+
if not text_content:
|
|
115
|
+
logger.error("Langflow input missing text content")
|
|
116
|
+
raise ValueError("No text content found in user message")
|
|
117
|
+
|
|
118
|
+
logger.debug("Prepared Langflow input (text_length=%d)", len(text_content))
|
|
119
|
+
return text_content
|
|
120
|
+
|
|
121
|
+
async def _aapi_call(
|
|
122
|
+
self,
|
|
123
|
+
query: str,
|
|
124
|
+
sessionId: str,
|
|
125
|
+
) -> Dict[str, Any]:
|
|
126
|
+
payload = {
|
|
127
|
+
"output_type": "chat",
|
|
128
|
+
"input_type": "chat",
|
|
129
|
+
"input_value": query,
|
|
130
|
+
"session_id": sessionId,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async with aiohttp.ClientSession() as session:
|
|
134
|
+
try:
|
|
135
|
+
async with session.post(
|
|
136
|
+
self.api_url,
|
|
137
|
+
headers=self.headers,
|
|
138
|
+
json=payload,
|
|
139
|
+
timeout=aiohttp.ClientTimeout(total=self.timeout),
|
|
140
|
+
) as response:
|
|
141
|
+
response.raise_for_status()
|
|
142
|
+
logger.info(
|
|
143
|
+
"Langflow API call succeeded (status=%s)", response.status
|
|
144
|
+
)
|
|
145
|
+
return await response.json()
|
|
146
|
+
|
|
147
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
|
|
148
|
+
logger.exception("Langflow API request failed")
|
|
149
|
+
raise aiohttp.ClientError(f"Async API request failed: {str(e)}") from e
|
|
150
|
+
|
|
151
|
+
except json.JSONDecodeError as e:
|
|
152
|
+
logger.exception("Langflow API response decoding failed")
|
|
153
|
+
raise ValueError(f"Failed to decode JSON response: {e}") from e
|
|
154
|
+
|
|
155
|
+
async def ainvoke(
|
|
156
|
+
self,
|
|
157
|
+
input: List[AgentMessage],
|
|
158
|
+
session_id: Union[str, None],
|
|
159
|
+
) -> AgentInvocationResponse:
|
|
160
|
+
"""Asynchronous invocation method - invokes the Langflow agent and converts to List[AgentMessage]."""
|
|
161
|
+
logger.info(
|
|
162
|
+
"Langflow ainvoke called (session_id=%s, input_messages=%d)",
|
|
163
|
+
session_id,
|
|
164
|
+
len(input),
|
|
165
|
+
)
|
|
166
|
+
agent_input = self._prepare_input(input)
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
agent_output = await self._aapi_call(
|
|
170
|
+
query=agent_input,
|
|
171
|
+
sessionId=session_id if session_id else uuid.uuid4(),
|
|
172
|
+
)
|
|
173
|
+
logger.debug(
|
|
174
|
+
"Langflow API returned payload with top-level keys: %s",
|
|
175
|
+
list(agent_output.keys()),
|
|
176
|
+
)
|
|
177
|
+
except Exception as e:
|
|
178
|
+
logger.exception("Error calling Langflow endpoint")
|
|
179
|
+
raise RuntimeError(f"Error calling langflow endpoint: {e}") from e
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
agent_trajectory = self._convert_api_output_to_messages(agent_output)
|
|
183
|
+
logger.info(
|
|
184
|
+
"Langflow conversion produced %d trajectory messages",
|
|
185
|
+
len(agent_trajectory),
|
|
186
|
+
)
|
|
187
|
+
return AgentInvocationResponse(
|
|
188
|
+
agent_trajectory=agent_trajectory,
|
|
189
|
+
)
|
|
190
|
+
except Exception as e:
|
|
191
|
+
logger.exception("Error processing Langflow response")
|
|
192
|
+
raise RuntimeError(f"Error processing langflow response: {e}") from e
|