langroid 0.58.2__py3-none-any.whl → 0.59.0b1__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.
- langroid/agent/base.py +39 -17
- langroid/agent/base.py-e +2216 -0
- langroid/agent/callbacks/chainlit.py +2 -1
- langroid/agent/chat_agent.py +73 -55
- langroid/agent/chat_agent.py-e +2086 -0
- langroid/agent/chat_document.py +7 -7
- langroid/agent/chat_document.py-e +513 -0
- langroid/agent/openai_assistant.py +9 -9
- langroid/agent/openai_assistant.py-e +882 -0
- langroid/agent/special/arangodb/arangodb_agent.py +10 -18
- langroid/agent/special/arangodb/arangodb_agent.py-e +648 -0
- langroid/agent/special/arangodb/tools.py +3 -3
- langroid/agent/special/doc_chat_agent.py +16 -14
- langroid/agent/special/lance_rag/critic_agent.py +2 -2
- langroid/agent/special/lance_rag/query_planner_agent.py +4 -4
- langroid/agent/special/lance_tools.py +6 -5
- langroid/agent/special/lance_tools.py-e +61 -0
- langroid/agent/special/neo4j/neo4j_chat_agent.py +3 -7
- langroid/agent/special/neo4j/neo4j_chat_agent.py-e +430 -0
- langroid/agent/special/relevance_extractor_agent.py +1 -1
- langroid/agent/special/sql/sql_chat_agent.py +11 -3
- langroid/agent/task.py +9 -87
- langroid/agent/task.py-e +2418 -0
- langroid/agent/tool_message.py +33 -17
- langroid/agent/tool_message.py-e +400 -0
- langroid/agent/tools/file_tools.py +4 -2
- langroid/agent/tools/file_tools.py-e +234 -0
- langroid/agent/tools/mcp/fastmcp_client.py +19 -6
- langroid/agent/tools/mcp/fastmcp_client.py-e +584 -0
- langroid/agent/tools/orchestration.py +22 -17
- langroid/agent/tools/orchestration.py-e +301 -0
- langroid/agent/tools/recipient_tool.py +3 -3
- langroid/agent/tools/task_tool.py +22 -16
- langroid/agent/tools/task_tool.py-e +249 -0
- langroid/agent/xml_tool_message.py +90 -35
- langroid/agent/xml_tool_message.py-e +392 -0
- langroid/cachedb/base.py +1 -1
- langroid/embedding_models/base.py +2 -2
- langroid/embedding_models/models.py +3 -7
- langroid/embedding_models/models.py-e +563 -0
- langroid/exceptions.py +4 -1
- langroid/language_models/azure_openai.py +2 -2
- langroid/language_models/azure_openai.py-e +134 -0
- langroid/language_models/base.py +6 -4
- langroid/language_models/base.py-e +812 -0
- langroid/language_models/client_cache.py +64 -0
- langroid/language_models/config.py +2 -4
- langroid/language_models/config.py-e +18 -0
- langroid/language_models/model_info.py +9 -1
- langroid/language_models/model_info.py-e +483 -0
- langroid/language_models/openai_gpt.py +119 -20
- langroid/language_models/openai_gpt.py-e +2280 -0
- langroid/language_models/provider_params.py +3 -22
- langroid/language_models/provider_params.py-e +153 -0
- langroid/mytypes.py +11 -4
- langroid/mytypes.py-e +132 -0
- langroid/parsing/code_parser.py +1 -1
- langroid/parsing/file_attachment.py +1 -1
- langroid/parsing/file_attachment.py-e +246 -0
- langroid/parsing/md_parser.py +14 -4
- langroid/parsing/md_parser.py-e +574 -0
- langroid/parsing/parser.py +22 -7
- langroid/parsing/parser.py-e +410 -0
- langroid/parsing/repo_loader.py +3 -1
- langroid/parsing/repo_loader.py-e +812 -0
- langroid/parsing/search.py +1 -1
- langroid/parsing/url_loader.py +17 -51
- langroid/parsing/url_loader.py-e +683 -0
- langroid/parsing/urls.py +5 -4
- langroid/parsing/urls.py-e +279 -0
- langroid/prompts/prompts_config.py +1 -1
- langroid/pydantic_v1/__init__.py +45 -6
- langroid/pydantic_v1/__init__.py-e +36 -0
- langroid/pydantic_v1/main.py +11 -4
- langroid/pydantic_v1/main.py-e +11 -0
- langroid/utils/configuration.py +13 -11
- langroid/utils/configuration.py-e +141 -0
- langroid/utils/constants.py +1 -1
- langroid/utils/constants.py-e +32 -0
- langroid/utils/globals.py +21 -5
- langroid/utils/globals.py-e +49 -0
- langroid/utils/html_logger.py +2 -1
- langroid/utils/html_logger.py-e +825 -0
- langroid/utils/object_registry.py +1 -1
- langroid/utils/object_registry.py-e +66 -0
- langroid/utils/pydantic_utils.py +55 -28
- langroid/utils/pydantic_utils.py-e +602 -0
- langroid/utils/types.py +2 -2
- langroid/utils/types.py-e +113 -0
- langroid/vector_store/base.py +3 -3
- langroid/vector_store/lancedb.py +5 -5
- langroid/vector_store/lancedb.py-e +404 -0
- langroid/vector_store/meilisearch.py +2 -2
- langroid/vector_store/pineconedb.py +4 -4
- langroid/vector_store/pineconedb.py-e +427 -0
- langroid/vector_store/postgres.py +1 -1
- langroid/vector_store/qdrantdb.py +3 -3
- langroid/vector_store/weaviatedb.py +1 -1
- {langroid-0.58.2.dist-info → langroid-0.59.0b1.dist-info}/METADATA +3 -2
- langroid-0.59.0b1.dist-info/RECORD +181 -0
- langroid/agent/special/doc_chat_task.py +0 -0
- langroid/mcp/__init__.py +0 -1
- langroid/mcp/server/__init__.py +0 -1
- langroid-0.58.2.dist-info/RECORD +0 -145
- {langroid-0.58.2.dist-info → langroid-0.59.0b1.dist-info}/WHEEL +0 -0
- {langroid-0.58.2.dist-info → langroid-0.59.0b1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,584 @@
|
|
1
|
+
import asyncio
|
2
|
+
import datetime
|
3
|
+
import logging
|
4
|
+
from base64 import b64decode
|
5
|
+
from io import BytesIO
|
6
|
+
from typing import Any, Dict, List, Optional, Tuple, Type, TypeAlias, cast
|
7
|
+
|
8
|
+
from dotenv import load_dotenv
|
9
|
+
from fastmcp.client import Client
|
10
|
+
from fastmcp.client.roots import (
|
11
|
+
RootsHandler,
|
12
|
+
RootsList,
|
13
|
+
)
|
14
|
+
from fastmcp.client.sampling import SamplingHandler
|
15
|
+
from fastmcp.client.transports import ClientTransport
|
16
|
+
from fastmcp.server import FastMCP
|
17
|
+
from mcp.client.session import (
|
18
|
+
LoggingFnT,
|
19
|
+
MessageHandlerFnT,
|
20
|
+
)
|
21
|
+
from mcp.types import (
|
22
|
+
BlobResourceContents,
|
23
|
+
CallToolResult,
|
24
|
+
EmbeddedResource,
|
25
|
+
ImageContent,
|
26
|
+
TextContent,
|
27
|
+
TextResourceContents,
|
28
|
+
Tool,
|
29
|
+
)
|
30
|
+
|
31
|
+
from langroid.agent.base import Agent
|
32
|
+
from langroid.agent.chat_document import ChatDocument
|
33
|
+
from langroid.agent.tool_message import ToolMessage
|
34
|
+
from langroid.parsing.file_attachment import FileAttachment
|
35
|
+
from pydantic import AnyUrl, BaseModel, Field, create_model
|
36
|
+
|
37
|
+
load_dotenv() # load environment variables from .env
|
38
|
+
|
39
|
+
FastMCPServerSpec: TypeAlias = str | FastMCP[Any] | ClientTransport | AnyUrl
|
40
|
+
|
41
|
+
|
42
|
+
class FastMCPClient:
|
43
|
+
"""A client for interacting with a FastMCP server.
|
44
|
+
|
45
|
+
Provides async context manager functionality to safely manage resources.
|
46
|
+
"""
|
47
|
+
|
48
|
+
logger = logging.getLogger(__name__)
|
49
|
+
_cm: Optional[Client] = None
|
50
|
+
client: Optional[Client] = None
|
51
|
+
|
52
|
+
def __init__(
|
53
|
+
self,
|
54
|
+
server: FastMCPServerSpec,
|
55
|
+
persist_connection: bool = False,
|
56
|
+
forward_images: bool = True,
|
57
|
+
forward_text_resources: bool = False,
|
58
|
+
forward_blob_resources: bool = False,
|
59
|
+
sampling_handler: SamplingHandler | None = None, # type: ignore
|
60
|
+
roots: RootsList | RootsHandler | None = None, # type: ignore
|
61
|
+
log_handler: LoggingFnT | None = None,
|
62
|
+
message_handler: MessageHandlerFnT | None = None,
|
63
|
+
read_timeout_seconds: datetime.timedelta | None = None,
|
64
|
+
) -> None:
|
65
|
+
"""Initialize the FastMCPClient.
|
66
|
+
|
67
|
+
Args:
|
68
|
+
server: FastMCP server or path to such a server
|
69
|
+
"""
|
70
|
+
self.server = server
|
71
|
+
self.client = None
|
72
|
+
self._cm = None
|
73
|
+
self.sampling_handler = sampling_handler
|
74
|
+
self.roots = roots
|
75
|
+
self.log_handler = log_handler
|
76
|
+
self.message_handler = message_handler
|
77
|
+
self.read_timeout_seconds = read_timeout_seconds
|
78
|
+
self.persist_connection = persist_connection
|
79
|
+
self.forward_text_resources = forward_text_resources
|
80
|
+
self.forward_blob_resources = forward_blob_resources
|
81
|
+
self.forward_images = forward_images
|
82
|
+
|
83
|
+
async def __aenter__(self) -> "FastMCPClient":
|
84
|
+
"""Enter the async context manager and connect inner client."""
|
85
|
+
# create inner client context manager
|
86
|
+
self._cm = Client(
|
87
|
+
self.server,
|
88
|
+
sampling_handler=self.sampling_handler,
|
89
|
+
roots=self.roots,
|
90
|
+
log_handler=self.log_handler,
|
91
|
+
message_handler=self.message_handler,
|
92
|
+
timeout=self.read_timeout_seconds,
|
93
|
+
)
|
94
|
+
# actually enter it (opens the session)
|
95
|
+
self.client = await self._cm.__aenter__() # type: ignore
|
96
|
+
return self
|
97
|
+
|
98
|
+
async def connect(self) -> None:
|
99
|
+
"""Open the underlying session."""
|
100
|
+
await self.__aenter__()
|
101
|
+
|
102
|
+
async def close(self) -> None:
|
103
|
+
"""Close the underlying session."""
|
104
|
+
await self.__aexit__(None, None, None)
|
105
|
+
|
106
|
+
async def __aexit__(
|
107
|
+
self,
|
108
|
+
exc_type: Optional[type[Exception]],
|
109
|
+
exc_val: Optional[Exception],
|
110
|
+
exc_tb: Optional[Any],
|
111
|
+
) -> None:
|
112
|
+
"""Exit the async context manager and close inner client."""
|
113
|
+
# exit and close the inner fastmcp.Client
|
114
|
+
if hasattr(self, "_cm"):
|
115
|
+
if self._cm is not None:
|
116
|
+
await self._cm.__aexit__(exc_type, exc_val, exc_tb) # type: ignore
|
117
|
+
self.client = None
|
118
|
+
self._cm = None
|
119
|
+
|
120
|
+
def __del__(self) -> None:
|
121
|
+
"""Warn about unclosed persistent connections."""
|
122
|
+
if self.client is not None and self.persist_connection:
|
123
|
+
import warnings
|
124
|
+
|
125
|
+
warnings.warn(
|
126
|
+
f"FastMCPClient with persist_connection=True was not properly closed. "
|
127
|
+
f"Connection to {self.server} may leak resources. "
|
128
|
+
f"Use 'async with' or call await client.close()",
|
129
|
+
ResourceWarning,
|
130
|
+
stacklevel=2,
|
131
|
+
)
|
132
|
+
|
133
|
+
def _schema_to_field(
|
134
|
+
self, name: str, schema: Dict[str, Any], prefix: str
|
135
|
+
) -> Tuple[Any, Any]:
|
136
|
+
"""Convert a JSON Schema snippet into a (type, Field) tuple.
|
137
|
+
|
138
|
+
Args:
|
139
|
+
name: Name of the field.
|
140
|
+
schema: JSON Schema for this field.
|
141
|
+
prefix: Prefix to use for nested model names.
|
142
|
+
|
143
|
+
Returns:
|
144
|
+
A tuple of (python_type, Field(...)) for create_model.
|
145
|
+
"""
|
146
|
+
t = schema.get("type")
|
147
|
+
default = schema.get("default", ...)
|
148
|
+
desc = schema.get("description")
|
149
|
+
# Object → nested BaseModel
|
150
|
+
if t == "object" and "properties" in schema:
|
151
|
+
sub_name = f"{prefix}_{name.capitalize()}"
|
152
|
+
sub_fields: Dict[str, Tuple[type, Any]] = {}
|
153
|
+
for k, sub_s in schema["properties"].items():
|
154
|
+
ftype, fld = self._schema_to_field(sub_name + k, sub_s, sub_name)
|
155
|
+
sub_fields[k] = (ftype, fld)
|
156
|
+
submodel = create_model( # type: ignore
|
157
|
+
sub_name,
|
158
|
+
__base__=BaseModel,
|
159
|
+
**sub_fields,
|
160
|
+
)
|
161
|
+
return submodel, Field(default=default, description=desc) # type: ignore
|
162
|
+
# Array → List of items
|
163
|
+
if t == "array" and "items" in schema:
|
164
|
+
item_type, _ = self._schema_to_field(name, schema["items"], prefix)
|
165
|
+
return List[item_type], Field(default=default, description=desc) # type: ignore
|
166
|
+
# Primitive types
|
167
|
+
if t == "string":
|
168
|
+
return str, Field(default=default, description=desc)
|
169
|
+
if t == "integer":
|
170
|
+
return int, Field(default=default, description=desc)
|
171
|
+
if t == "number":
|
172
|
+
return float, Field(default=default, description=desc)
|
173
|
+
if t == "boolean":
|
174
|
+
return bool, Field(default=default, description=desc)
|
175
|
+
# Fallback or unions
|
176
|
+
if any(key in schema for key in ("oneOf", "anyOf", "allOf")):
|
177
|
+
self.logger.warning("Unsupported union schema in field %s; using Any", name)
|
178
|
+
return Any, Field(default=default, description=desc)
|
179
|
+
# Default fallback
|
180
|
+
return Any, Field(default=default, description=desc)
|
181
|
+
|
182
|
+
async def get_tool_async(self, tool_name: str) -> Type[ToolMessage]:
|
183
|
+
"""
|
184
|
+
Create a Langroid ToolMessage subclass from the MCP Tool
|
185
|
+
with the given `tool_name`.
|
186
|
+
"""
|
187
|
+
if not self.client:
|
188
|
+
if self.persist_connection:
|
189
|
+
await self.connect()
|
190
|
+
assert self.client
|
191
|
+
else:
|
192
|
+
raise RuntimeError(
|
193
|
+
"Client not initialized. Use async with FastMCPClient."
|
194
|
+
)
|
195
|
+
target = await self.get_mcp_tool_async(tool_name)
|
196
|
+
if target is None:
|
197
|
+
raise ValueError(f"No tool named {tool_name}")
|
198
|
+
props = target.inputSchema.get("properties", {})
|
199
|
+
fields: Dict[str, Tuple[type, Any]] = {}
|
200
|
+
for fname, schema in props.items():
|
201
|
+
ftype, fld = self._schema_to_field(fname, schema, target.name)
|
202
|
+
fields[fname] = (ftype, fld)
|
203
|
+
|
204
|
+
# Convert target.name to CamelCase and add Tool suffix
|
205
|
+
parts = target.name.replace("-", "_").split("_")
|
206
|
+
camel_case = "".join(part.capitalize() for part in parts)
|
207
|
+
model_name = f"{camel_case}Tool"
|
208
|
+
|
209
|
+
from langroid.agent.tool_message import ToolMessage as _BaseToolMessage
|
210
|
+
|
211
|
+
# IMPORTANT: Avoid clashes with reserved field names in Langroid ToolMessage!
|
212
|
+
# First figure out which field names are reserved
|
213
|
+
reserved = set(_BaseToolMessage.__annotations__.keys())
|
214
|
+
reserved.update(["recipient", "_handler", "name"])
|
215
|
+
renamed: Dict[str, str] = {}
|
216
|
+
new_fields: Dict[str, Tuple[type, Any]] = {}
|
217
|
+
for fname, (ftype, fld) in fields.items():
|
218
|
+
if fname in reserved:
|
219
|
+
new_name = fname + "__"
|
220
|
+
renamed[fname] = new_name
|
221
|
+
new_fields[new_name] = (ftype, fld)
|
222
|
+
else:
|
223
|
+
new_fields[fname] = (ftype, fld)
|
224
|
+
# now replace fields with our renamed‐aware mapping
|
225
|
+
fields = new_fields
|
226
|
+
|
227
|
+
# create Langroid ToolMessage subclass, with expected fields.
|
228
|
+
tool_model = cast(
|
229
|
+
Type[ToolMessage],
|
230
|
+
create_model( # type: ignore[call-overload]
|
231
|
+
model_name,
|
232
|
+
request=(str, target.name),
|
233
|
+
purpose=(str, target.description or f"Use the tool {target.name}"),
|
234
|
+
__base__=ToolMessage,
|
235
|
+
**fields,
|
236
|
+
),
|
237
|
+
)
|
238
|
+
# Store ALL client configuration needed to recreate a client
|
239
|
+
client_config = {
|
240
|
+
"server": self.server,
|
241
|
+
"sampling_handler": self.sampling_handler,
|
242
|
+
"roots": self.roots,
|
243
|
+
"log_handler": self.log_handler,
|
244
|
+
"message_handler": self.message_handler,
|
245
|
+
"read_timeout_seconds": self.read_timeout_seconds,
|
246
|
+
}
|
247
|
+
|
248
|
+
tool_model._client_config = client_config # type: ignore [attr-defined]
|
249
|
+
tool_model._renamed_fields = renamed # type: ignore[attr-defined]
|
250
|
+
|
251
|
+
# 2) define an arg-free call_tool_async()
|
252
|
+
async def call_tool_async(itself: ToolMessage) -> Any:
|
253
|
+
from langroid.agent.tools.mcp.fastmcp_client import FastMCPClient
|
254
|
+
|
255
|
+
# pack up the payload
|
256
|
+
payload = itself.model_dump(
|
257
|
+
exclude=itself.model_config["json_schema_extra"]["exclude"].union(
|
258
|
+
["request", "purpose"]
|
259
|
+
),
|
260
|
+
)
|
261
|
+
|
262
|
+
# restore any renamed fields
|
263
|
+
for orig, new in itself.__class__._renamed_fields.items(): # type: ignore
|
264
|
+
if new in payload:
|
265
|
+
payload[orig] = payload.pop(new)
|
266
|
+
|
267
|
+
client_cfg = getattr(itself.__class__, "_client_config", None) # type: ignore
|
268
|
+
if not client_cfg:
|
269
|
+
# Fallback or error - ideally _client_config should always exist
|
270
|
+
raise RuntimeError(f"Client config missing on {itself.__class__}")
|
271
|
+
|
272
|
+
# Connect the client if not yet connected and keep the connection open
|
273
|
+
if self.persist_connection:
|
274
|
+
if not self.client:
|
275
|
+
await self.connect()
|
276
|
+
|
277
|
+
return await self.call_mcp_tool(itself.request, payload)
|
278
|
+
|
279
|
+
# open a fresh client, call the tool, then close
|
280
|
+
async with FastMCPClient(**client_cfg) as client: # type: ignore
|
281
|
+
return await client.call_mcp_tool(itself.request, payload)
|
282
|
+
|
283
|
+
tool_model.call_tool_async = call_tool_async # type: ignore
|
284
|
+
|
285
|
+
if not hasattr(tool_model, "handle_async"):
|
286
|
+
# 3) define handle_async() method with optional agent parameter
|
287
|
+
from typing import Union
|
288
|
+
|
289
|
+
async def handle_async(
|
290
|
+
self: ToolMessage, agent: Optional[Agent] = None
|
291
|
+
) -> Union[str, Optional[ChatDocument]]:
|
292
|
+
"""
|
293
|
+
Auto-generated handler for MCP tool. Returns ChatDocument with files
|
294
|
+
if files are present and agent is provided, otherwise returns text.
|
295
|
+
|
296
|
+
To override: define your own handle_async method with matching signature
|
297
|
+
if you need file handling, or simpler signature if you only need text.
|
298
|
+
"""
|
299
|
+
response = await self.call_tool_async() # type: ignore[attr-defined]
|
300
|
+
if response is None:
|
301
|
+
return None
|
302
|
+
|
303
|
+
content, files = response
|
304
|
+
|
305
|
+
# If we have files and an agent is provided, return a ChatDocument
|
306
|
+
if files and agent is not None:
|
307
|
+
return agent.create_agent_response(
|
308
|
+
content=content,
|
309
|
+
files=files,
|
310
|
+
)
|
311
|
+
else:
|
312
|
+
# Otherwise, just return the text content
|
313
|
+
return str(content) if content is not None else None
|
314
|
+
|
315
|
+
# add the handle_async() method to the tool model
|
316
|
+
tool_model.handle_async = handle_async # type: ignore
|
317
|
+
|
318
|
+
return tool_model
|
319
|
+
|
320
|
+
async def get_tools_async(self) -> List[Type[ToolMessage]]:
|
321
|
+
"""
|
322
|
+
Get all available tools as Langroid ToolMessage classes,
|
323
|
+
handling nested schemas, with `handle_async` methods
|
324
|
+
"""
|
325
|
+
if not self.client:
|
326
|
+
if self.persist_connection:
|
327
|
+
await self.connect()
|
328
|
+
assert self.client
|
329
|
+
else:
|
330
|
+
raise RuntimeError(
|
331
|
+
"Client not initialized. Use async with FastMCPClient."
|
332
|
+
)
|
333
|
+
resp = await self.client.list_tools()
|
334
|
+
return [await self.get_tool_async(t.name) for t in resp]
|
335
|
+
|
336
|
+
async def get_mcp_tool_async(self, name: str) -> Optional[Tool]:
|
337
|
+
"""Find the "original" MCP Tool (i.e. of type mcp.types.Tool) on the server
|
338
|
+
matching `name`, or None if missing. This contains the metadata for the tool:
|
339
|
+
name, description, inputSchema, etc.
|
340
|
+
|
341
|
+
Args:
|
342
|
+
name: Name of the tool to look up.
|
343
|
+
|
344
|
+
Returns:
|
345
|
+
The raw Tool object from the server, or None.
|
346
|
+
"""
|
347
|
+
if not self.client:
|
348
|
+
if self.persist_connection:
|
349
|
+
await self.connect()
|
350
|
+
assert self.client
|
351
|
+
else:
|
352
|
+
raise RuntimeError(
|
353
|
+
"Client not initialized. Use async with FastMCPClient."
|
354
|
+
)
|
355
|
+
resp: List[Tool] = await self.client.list_tools()
|
356
|
+
return next((t for t in resp if t.name == name), None)
|
357
|
+
|
358
|
+
def _convert_tool_result(
|
359
|
+
self,
|
360
|
+
tool_name: str,
|
361
|
+
result: CallToolResult,
|
362
|
+
) -> Optional[str | tuple[str, list[FileAttachment]]]:
|
363
|
+
if result.isError:
|
364
|
+
# Log more detailed error information
|
365
|
+
error_content = None
|
366
|
+
if result.content and len(result.content) > 0:
|
367
|
+
try:
|
368
|
+
error_content = [
|
369
|
+
item.text if hasattr(item, "text") else str(item)
|
370
|
+
for item in result.content
|
371
|
+
]
|
372
|
+
except Exception as e:
|
373
|
+
error_content = [f"Could not extract error content: {str(e)}"]
|
374
|
+
|
375
|
+
self.logger.error(
|
376
|
+
f"Error calling MCP tool {tool_name}. Details: {error_content}"
|
377
|
+
)
|
378
|
+
return f"ERROR: Tool call failed - {error_content}"
|
379
|
+
|
380
|
+
results_text = [
|
381
|
+
item.text for item in result.content if isinstance(item, TextContent)
|
382
|
+
]
|
383
|
+
results_file = []
|
384
|
+
|
385
|
+
for item in result.content:
|
386
|
+
if isinstance(item, ImageContent) and self.forward_images:
|
387
|
+
results_file.append(
|
388
|
+
FileAttachment.from_bytes(
|
389
|
+
b64decode(item.data),
|
390
|
+
mime_type=item.mimeType,
|
391
|
+
)
|
392
|
+
)
|
393
|
+
elif isinstance(item, EmbeddedResource):
|
394
|
+
if (
|
395
|
+
isinstance(item.resource, TextResourceContents)
|
396
|
+
and self.forward_text_resources
|
397
|
+
):
|
398
|
+
results_text.append(item.resource.text)
|
399
|
+
elif (
|
400
|
+
isinstance(item.resource, BlobResourceContents)
|
401
|
+
and self.forward_blob_resources
|
402
|
+
):
|
403
|
+
results_file.append(
|
404
|
+
FileAttachment.from_io(
|
405
|
+
BytesIO(b64decode(item.resource.blob)),
|
406
|
+
mime_type=item.resource.mimeType,
|
407
|
+
)
|
408
|
+
)
|
409
|
+
|
410
|
+
return "\n".join(results_text), results_file
|
411
|
+
|
412
|
+
async def call_mcp_tool(
|
413
|
+
self, tool_name: str, arguments: Dict[str, Any]
|
414
|
+
) -> Optional[tuple[str, list[FileAttachment]]]:
|
415
|
+
"""Call an MCP tool with the given arguments.
|
416
|
+
|
417
|
+
Args:
|
418
|
+
tool_name: Name of the tool to call.
|
419
|
+
arguments: Arguments to pass to the tool.
|
420
|
+
|
421
|
+
Returns:
|
422
|
+
The result of the tool call.
|
423
|
+
"""
|
424
|
+
if not self.client:
|
425
|
+
if self.persist_connection:
|
426
|
+
await self.connect()
|
427
|
+
assert self.client
|
428
|
+
else:
|
429
|
+
raise RuntimeError(
|
430
|
+
"Client not initialized. Use async with FastMCPClient."
|
431
|
+
)
|
432
|
+
result: CallToolResult = await self.client.session.call_tool(
|
433
|
+
tool_name,
|
434
|
+
arguments,
|
435
|
+
)
|
436
|
+
results = self._convert_tool_result(tool_name, result)
|
437
|
+
|
438
|
+
if isinstance(results, str):
|
439
|
+
return results, []
|
440
|
+
|
441
|
+
return results
|
442
|
+
|
443
|
+
|
444
|
+
# ==============================================================================
|
445
|
+
# Convenience functions (wrappers around FastMCPClient methods)
|
446
|
+
# These are useful for one-off calls without needing to manage the
|
447
|
+
# FastMCPClient context explicitly.
|
448
|
+
# ==============================================================================
|
449
|
+
|
450
|
+
|
451
|
+
async def get_tool_async(
|
452
|
+
server: FastMCPServerSpec,
|
453
|
+
tool_name: str,
|
454
|
+
**client_kwargs: Any,
|
455
|
+
) -> Type[ToolMessage]:
|
456
|
+
"""Get a single Langroid ToolMessage subclass for a specific MCP tool name (async).
|
457
|
+
|
458
|
+
This is a convenience wrapper that creates a temporary FastMCPClient.
|
459
|
+
|
460
|
+
Args:
|
461
|
+
server: Specification of the FastMCP server to connect to.
|
462
|
+
tool_name: The name of the tool to retrieve.
|
463
|
+
**client_kwargs: Additional keyword arguments to pass to the
|
464
|
+
FastMCPClient constructor (e.g., sampling_handler, roots).
|
465
|
+
|
466
|
+
Returns:
|
467
|
+
A dynamically created Langroid ToolMessage subclass representing the
|
468
|
+
requested tool.
|
469
|
+
"""
|
470
|
+
async with FastMCPClient(server, **client_kwargs) as client:
|
471
|
+
return await client.get_tool_async(tool_name)
|
472
|
+
|
473
|
+
|
474
|
+
def get_tool(
|
475
|
+
server: FastMCPServerSpec,
|
476
|
+
tool_name: str,
|
477
|
+
**client_kwargs: Any,
|
478
|
+
) -> Type[ToolMessage]:
|
479
|
+
"""Get a single Langroid ToolMessage subclass
|
480
|
+
for a specific MCP tool name (synchronous).
|
481
|
+
|
482
|
+
This is a convenience wrapper that creates a temporary FastMCPClient and runs the
|
483
|
+
async `get_tool_async` function using `asyncio.run()`.
|
484
|
+
|
485
|
+
Args:
|
486
|
+
server: Specification of the FastMCP server to connect to.
|
487
|
+
tool_name: The name of the tool to retrieve.
|
488
|
+
**client_kwargs: Additional keyword arguments to pass to the
|
489
|
+
FastMCPClient constructor (e.g., sampling_handler, roots).
|
490
|
+
|
491
|
+
Returns:
|
492
|
+
A dynamically created Langroid ToolMessage subclass representing the
|
493
|
+
requested tool.
|
494
|
+
"""
|
495
|
+
return asyncio.run(get_tool_async(server, tool_name, **client_kwargs))
|
496
|
+
|
497
|
+
|
498
|
+
async def get_tools_async(
|
499
|
+
server: FastMCPServerSpec,
|
500
|
+
**client_kwargs: Any,
|
501
|
+
) -> List[Type[ToolMessage]]:
|
502
|
+
"""Get all available tools as Langroid ToolMessage subclasses (async).
|
503
|
+
|
504
|
+
This is a convenience wrapper that creates a temporary FastMCPClient.
|
505
|
+
|
506
|
+
Args:
|
507
|
+
server: Specification of the FastMCP server to connect to.
|
508
|
+
**client_kwargs: Additional keyword arguments to pass to the
|
509
|
+
FastMCPClient constructor (e.g., sampling_handler, roots).
|
510
|
+
|
511
|
+
Returns:
|
512
|
+
A list of dynamically created Langroid ToolMessage subclasses
|
513
|
+
representing all available tools on the server.
|
514
|
+
"""
|
515
|
+
async with FastMCPClient(server, **client_kwargs) as client:
|
516
|
+
return await client.get_tools_async()
|
517
|
+
|
518
|
+
|
519
|
+
def get_tools(
|
520
|
+
server: FastMCPServerSpec,
|
521
|
+
**client_kwargs: Any,
|
522
|
+
) -> List[Type[ToolMessage]]:
|
523
|
+
"""Get all available tools as Langroid ToolMessage subclasses (synchronous).
|
524
|
+
|
525
|
+
This is a convenience wrapper that creates a temporary FastMCPClient and runs the
|
526
|
+
async `get_tools_async` function using `asyncio.run()`.
|
527
|
+
|
528
|
+
Args:
|
529
|
+
server: Specification of the FastMCP server to connect to.
|
530
|
+
**client_kwargs: Additional keyword arguments to pass to the
|
531
|
+
FastMCPClient constructor (e.g., sampling_handler, roots).
|
532
|
+
|
533
|
+
Returns:
|
534
|
+
A list of dynamically created Langroid ToolMessage subclasses
|
535
|
+
representing all available tools on the server.
|
536
|
+
"""
|
537
|
+
return asyncio.run(get_tools_async(server, **client_kwargs))
|
538
|
+
|
539
|
+
|
540
|
+
async def get_mcp_tool_async(
|
541
|
+
server: FastMCPServerSpec,
|
542
|
+
name: str,
|
543
|
+
**client_kwargs: Any,
|
544
|
+
) -> Optional[Tool]:
|
545
|
+
"""Get the raw MCP Tool object for a specific tool name (async).
|
546
|
+
|
547
|
+
This is a convenience wrapper that creates a temporary FastMCPClient to
|
548
|
+
retrieve the tool definition from the server.
|
549
|
+
|
550
|
+
Args:
|
551
|
+
server: Specification of the FastMCP server to connect to.
|
552
|
+
name: The name of the tool to look up.
|
553
|
+
**client_kwargs: Additional keyword arguments to pass to the
|
554
|
+
FastMCPClient constructor.
|
555
|
+
|
556
|
+
Returns:
|
557
|
+
The raw `mcp.types.Tool` object from the server, or `None` if the tool
|
558
|
+
is not found.
|
559
|
+
"""
|
560
|
+
async with FastMCPClient(server, **client_kwargs) as client:
|
561
|
+
return await client.get_mcp_tool_async(name)
|
562
|
+
|
563
|
+
|
564
|
+
async def get_mcp_tools_async(
|
565
|
+
server: FastMCPServerSpec,
|
566
|
+
**client_kwargs: Any,
|
567
|
+
) -> List[Tool]:
|
568
|
+
"""Get all available raw MCP Tool objects from the server (async).
|
569
|
+
|
570
|
+
This is a convenience wrapper that creates a temporary FastMCPClient to
|
571
|
+
retrieve the list of tool definitions from the server.
|
572
|
+
|
573
|
+
Args:
|
574
|
+
server: Specification of the FastMCP server to connect to.
|
575
|
+
**client_kwargs: Additional keyword arguments to pass to the
|
576
|
+
FastMCPClient constructor.
|
577
|
+
|
578
|
+
Returns:
|
579
|
+
A list of raw `mcp.types.Tool` objects available on the server.
|
580
|
+
"""
|
581
|
+
async with FastMCPClient(server, **client_kwargs) as client:
|
582
|
+
if not client.client:
|
583
|
+
raise RuntimeError("Client not initialized. Use async with FastMCPClient.")
|
584
|
+
return await client.client.list_tools()
|
@@ -5,11 +5,12 @@ termination, routing to another agent, etc.
|
|
5
5
|
|
6
6
|
from typing import Any, List, Tuple
|
7
7
|
|
8
|
+
from pydantic import ConfigDict, field_validator
|
9
|
+
|
8
10
|
from langroid.agent.chat_agent import ChatAgent
|
9
11
|
from langroid.agent.chat_document import ChatDocument
|
10
12
|
from langroid.agent.tool_message import ToolMessage
|
11
13
|
from langroid.mytypes import Entity
|
12
|
-
from langroid.pydantic_v1 import Extra
|
13
14
|
from langroid.utils.types import to_string
|
14
15
|
|
15
16
|
|
@@ -48,6 +49,12 @@ class DoneTool(ToolMessage):
|
|
48
49
|
request: str = "done_tool"
|
49
50
|
content: str = ""
|
50
51
|
|
52
|
+
@field_validator("content", mode="before")
|
53
|
+
@classmethod
|
54
|
+
def convert_content_to_string(cls, v: Any) -> str:
|
55
|
+
"""Convert content to string if it's not already."""
|
56
|
+
return str(v) if v is not None else ""
|
57
|
+
|
51
58
|
def response(self, agent: ChatAgent) -> ChatDocument:
|
52
59
|
return agent.create_agent_response(
|
53
60
|
content=self.content,
|
@@ -90,14 +97,13 @@ class ResultTool(ToolMessage):
|
|
90
97
|
purpose: str = "Ignored; Wrapper for a structured message"
|
91
98
|
id: str = "" # placeholder for OpenAI-API tool_call_id
|
92
99
|
|
93
|
-
|
94
|
-
extra
|
95
|
-
arbitrary_types_allowed
|
96
|
-
|
97
|
-
validate_assignment
|
98
|
-
|
99
|
-
|
100
|
-
schema_extra = {"exclude": {"purpose", "id", "strict"}}
|
100
|
+
model_config = ConfigDict(
|
101
|
+
extra="allow",
|
102
|
+
arbitrary_types_allowed=False,
|
103
|
+
validate_default=True,
|
104
|
+
validate_assignment=True,
|
105
|
+
json_schema_extra={"exclude": ["purpose", "id", "strict"]},
|
106
|
+
)
|
101
107
|
|
102
108
|
def handle(self) -> AgentDoneTool:
|
103
109
|
return AgentDoneTool(tools=[self])
|
@@ -132,14 +138,13 @@ class FinalResultTool(ToolMessage):
|
|
132
138
|
id: str = "" # placeholder for OpenAI-API tool_call_id
|
133
139
|
_allow_llm_use: bool = False
|
134
140
|
|
135
|
-
|
136
|
-
extra
|
137
|
-
arbitrary_types_allowed
|
138
|
-
|
139
|
-
validate_assignment
|
140
|
-
|
141
|
-
|
142
|
-
schema_extra = {"exclude": {"purpose", "id", "strict"}}
|
141
|
+
model_config = ConfigDict(
|
142
|
+
extra="allow",
|
143
|
+
arbitrary_types_allowed=False,
|
144
|
+
validate_default=True,
|
145
|
+
validate_assignment=True,
|
146
|
+
json_schema_extra={"exclude": ["purpose", "id", "strict"]},
|
147
|
+
)
|
143
148
|
|
144
149
|
|
145
150
|
class PassTool(ToolMessage):
|