langroid 0.54.1__py3-none-any.whl → 0.55.0__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 CHANGED
@@ -251,6 +251,172 @@ class Agent(ABC):
251
251
  def clear_dialog(self) -> None:
252
252
  self.dialog = []
253
253
 
254
+ def _analyze_handler_params(
255
+ self, handler_method: Any
256
+ ) -> Tuple[bool, Optional[str], Optional[str]]:
257
+ """
258
+ Analyze parameters of a handler method to determine their types.
259
+
260
+ Returns:
261
+ Tuple of (has_annotations, agent_param_name, chat_doc_param_name)
262
+ - has_annotations: True if useful type annotations were found
263
+ - agent_param_name: Name of the agent parameter if found
264
+ - chat_doc_param_name: Name of the chat_doc parameter if found
265
+ """
266
+ sig = inspect.signature(handler_method)
267
+ params = list(sig.parameters.values())
268
+ # Remove 'self' parameter
269
+ params = [p for p in params if p.name != "self"]
270
+
271
+ agent_param = None
272
+ chat_doc_param = None
273
+ has_annotations = False
274
+
275
+ for param in params:
276
+ # First try type annotations
277
+ if param.annotation != inspect.Parameter.empty:
278
+ ann_str = str(param.annotation)
279
+ # Check for Agent-like types
280
+ if (
281
+ param.annotation == self.__class__
282
+ or "Agent" in ann_str
283
+ or (
284
+ hasattr(param.annotation, "__name__")
285
+ and "Agent" in param.annotation.__name__
286
+ )
287
+ ):
288
+ agent_param = param.name
289
+ has_annotations = True
290
+ # Check for ChatDocument-like types
291
+ elif "ChatDocument" in ann_str or "ChatDoc" in ann_str:
292
+ chat_doc_param = param.name
293
+ has_annotations = True
294
+
295
+ # Fallback to parameter names
296
+ elif param.name == "agent":
297
+ agent_param = param.name
298
+ elif param.name == "chat_doc":
299
+ chat_doc_param = param.name
300
+
301
+ return has_annotations, agent_param, chat_doc_param
302
+
303
+ @no_type_check
304
+ def _create_handler_wrapper(
305
+ self,
306
+ message_class: Type[ToolMessage],
307
+ handler_method: Any,
308
+ is_async: bool = False,
309
+ ) -> Any:
310
+ """
311
+ Create a wrapper function for a handler method based on its signature.
312
+
313
+ Args:
314
+ message_class: The ToolMessage class
315
+ handler_method: The handle/handle_async method
316
+ is_async: Whether this is for an async handler
317
+
318
+ Returns:
319
+ Appropriate wrapper function
320
+ """
321
+ sig = inspect.signature(handler_method)
322
+ params = list(sig.parameters.values())
323
+ params = [p for p in params if p.name != "self"]
324
+
325
+ has_annotations, agent_param, chat_doc_param = self._analyze_handler_params(
326
+ handler_method,
327
+ )
328
+
329
+ # Build wrapper based on found parameters
330
+ if len(params) == 0:
331
+ if is_async:
332
+
333
+ async def wrapper(obj: Any) -> Any:
334
+ return await obj.handle_async()
335
+
336
+ else:
337
+
338
+ def wrapper(obj: Any) -> Any:
339
+ return obj.handle()
340
+
341
+ elif agent_param and chat_doc_param:
342
+ # Both parameters present - build wrapper respecting their order
343
+ param_names = [p.name for p in params]
344
+ if param_names.index(agent_param) < param_names.index(chat_doc_param):
345
+ # agent is first parameter
346
+ if is_async:
347
+
348
+ async def wrapper(obj: Any, chat_doc: Any) -> Any:
349
+ return await obj.handle_async(self, chat_doc)
350
+
351
+ else:
352
+
353
+ def wrapper(obj: Any, chat_doc: Any) -> Any:
354
+ return obj.handle(self, chat_doc)
355
+
356
+ else:
357
+ # chat_doc is first parameter
358
+ if is_async:
359
+
360
+ async def wrapper(obj: Any, chat_doc: Any) -> Any:
361
+ return await obj.handle_async(chat_doc, self)
362
+
363
+ else:
364
+
365
+ def wrapper(obj: Any, chat_doc: Any) -> Any:
366
+ return obj.handle(chat_doc, self)
367
+
368
+ elif agent_param and not chat_doc_param:
369
+ # Only agent parameter
370
+ if is_async:
371
+
372
+ async def wrapper(obj: Any) -> Any:
373
+ return await obj.handle_async(self)
374
+
375
+ else:
376
+
377
+ def wrapper(obj: Any) -> Any:
378
+ return obj.handle(self)
379
+
380
+ elif chat_doc_param and not agent_param:
381
+ # Only chat_doc parameter
382
+ if is_async:
383
+
384
+ async def wrapper(obj: Any, chat_doc: Any) -> Any:
385
+ return await obj.handle_async(chat_doc)
386
+
387
+ else:
388
+
389
+ def wrapper(obj: Any, chat_doc: Any) -> Any:
390
+ return obj.handle(chat_doc)
391
+
392
+ else:
393
+ # No recognized parameters - backward compatibility
394
+ # Assume single parameter is chat_doc (legacy behavior)
395
+ if len(params) == 1:
396
+ if is_async:
397
+
398
+ async def wrapper(obj: Any, chat_doc: Any) -> Any:
399
+ return await obj.handle_async(chat_doc)
400
+
401
+ else:
402
+
403
+ def wrapper(obj: Any, chat_doc: Any) -> Any:
404
+ return obj.handle(chat_doc)
405
+
406
+ else:
407
+ # Multiple unrecognized parameters - best guess
408
+ if is_async:
409
+
410
+ async def wrapper(obj: Any, chat_doc: Any) -> Any:
411
+ return await obj.handle_async(chat_doc)
412
+
413
+ else:
414
+
415
+ def wrapper(obj: Any, chat_doc: Any) -> Any:
416
+ return obj.handle(chat_doc)
417
+
418
+ return wrapper
419
+
254
420
  def _get_tool_list(
255
421
  self, message_class: Optional[Type[ToolMessage]] = None
256
422
  ) -> List[str]:
@@ -304,13 +470,12 @@ class Agent(ABC):
304
470
  in one place, i.e. in the message class.
305
471
  See `tests/main/test_stateless_tool_messages.py` for an example.
306
472
  """
307
- has_chat_doc_arg = (
308
- len(inspect.signature(message_class.handle).parameters) > 1
473
+ wrapper = self._create_handler_wrapper(
474
+ message_class,
475
+ message_class.handle,
476
+ is_async=False,
309
477
  )
310
- if has_chat_doc_arg:
311
- setattr(self, handler, lambda obj, chat_doc: obj.handle(chat_doc))
312
- else:
313
- setattr(self, handler, lambda obj: obj.handle())
478
+ setattr(self, handler, wrapper)
314
479
  elif (
315
480
  hasattr(message_class, "response")
316
481
  and inspect.isfunction(message_class.response)
@@ -320,11 +485,17 @@ class Agent(ABC):
320
485
  len(inspect.signature(message_class.response).parameters) > 2
321
486
  )
322
487
  if has_chat_doc_arg:
323
- setattr(
324
- self, handler, lambda obj, chat_doc: obj.response(self, chat_doc)
325
- )
488
+
489
+ def response_wrapper_with_chat_doc(obj: Any, chat_doc: Any) -> Any:
490
+ return obj.response(self, chat_doc)
491
+
492
+ setattr(self, handler, response_wrapper_with_chat_doc)
326
493
  else:
327
- setattr(self, handler, lambda obj: obj.response(self))
494
+
495
+ def response_wrapper_no_chat_doc(obj: Any) -> Any:
496
+ return obj.response(self)
497
+
498
+ setattr(self, handler, response_wrapper_no_chat_doc)
328
499
 
329
500
  if hasattr(message_class, "handle_message_fallback") and (
330
501
  inspect.isfunction(message_class.handle_message_fallback)
@@ -334,10 +505,13 @@ class Agent(ABC):
334
505
  # `handle_message_fallback` method (which does nothing).
335
506
  # It's possible multiple tool messages have a `handle_message_fallback`,
336
507
  # in which case, the last one inserted will be used.
508
+ def fallback_wrapper(msg: Any) -> Any:
509
+ return message_class.handle_message_fallback(self, msg)
510
+
337
511
  setattr(
338
512
  self,
339
513
  "handle_message_fallback",
340
- lambda msg: message_class.handle_message_fallback(self, msg),
514
+ fallback_wrapper,
341
515
  )
342
516
 
343
517
  async_handler_name = f"{handler}_async"
@@ -346,23 +520,12 @@ class Agent(ABC):
346
520
  and inspect.isfunction(message_class.handle_async)
347
521
  and not hasattr(self, async_handler_name)
348
522
  ):
349
- has_chat_doc_arg = (
350
- len(inspect.signature(message_class.handle_async).parameters) > 1
523
+ wrapper = self._create_handler_wrapper(
524
+ message_class,
525
+ message_class.handle_async,
526
+ is_async=True,
351
527
  )
352
-
353
- if has_chat_doc_arg:
354
-
355
- @no_type_check
356
- async def handler(obj, chat_doc):
357
- return await obj.handle_async(chat_doc)
358
-
359
- else:
360
-
361
- @no_type_check
362
- async def handler(obj):
363
- return await obj.handle_async()
364
-
365
- setattr(self, async_handler_name, handler)
528
+ setattr(self, async_handler_name, wrapper)
366
529
  elif (
367
530
  hasattr(message_class, "response_async")
368
531
  and inspect.isfunction(message_class.response_async)
@@ -0,0 +1,151 @@
1
+ """Parser for done sequence DSL (Domain Specific Language).
2
+
3
+ Converts string patterns into DoneSequence objects for convenient task completion
4
+ configuration.
5
+
6
+ Examples:
7
+ "T, A" -> Tool followed by Agent response
8
+ "T[calculator], A" -> Specific tool 'calculator' followed by Agent response
9
+ "L, T, A, L" -> LLM, Tool, Agent, LLM sequence
10
+ "C[quit|exit]" -> Content matching regex pattern
11
+ """
12
+
13
+ import re
14
+ from typing import List, Union
15
+
16
+ from langroid.pydantic_v1 import BaseModel
17
+
18
+ from .task import AgentEvent, DoneSequence, EventType
19
+
20
+
21
+ def parse_done_sequence(sequence: Union[str, DoneSequence]) -> DoneSequence:
22
+ """Parse a string pattern or return existing DoneSequence unchanged.
23
+
24
+ Args:
25
+ sequence: Either a DoneSequence object or a string pattern to parse
26
+
27
+ Returns:
28
+ DoneSequence object
29
+
30
+ Raises:
31
+ ValueError: If the string pattern is invalid
32
+ """
33
+ if isinstance(sequence, DoneSequence):
34
+ return sequence
35
+
36
+ if not isinstance(sequence, str):
37
+ raise ValueError(f"Expected string or DoneSequence, got {type(sequence)}")
38
+
39
+ events = _parse_string_pattern(sequence)
40
+ return DoneSequence(events=events)
41
+
42
+
43
+ def _parse_string_pattern(pattern: str) -> List[AgentEvent]:
44
+ """Parse a string pattern into a list of AgentEvent objects.
45
+
46
+ Pattern format:
47
+ - Single letter codes: T, A, L, U, N, C
48
+ - Specific tools: T[tool_name]
49
+ - Content match: C[regex_pattern]
50
+ - Separated by commas, spaces allowed
51
+
52
+ Args:
53
+ pattern: String pattern to parse
54
+
55
+ Returns:
56
+ List of AgentEvent objects
57
+
58
+ Raises:
59
+ ValueError: If pattern is invalid
60
+ """
61
+ events = []
62
+
63
+ # Split by comma and strip whitespace
64
+ parts = [p.strip() for p in pattern.split(",")]
65
+
66
+ for part in parts:
67
+ if not part:
68
+ continue
69
+
70
+ event = _parse_event_token(part)
71
+ events.append(event)
72
+
73
+ if not events:
74
+ raise ValueError(f"No valid events found in pattern: {pattern}")
75
+
76
+ return events
77
+
78
+
79
+ def _parse_event_token(token: str) -> AgentEvent:
80
+ """Parse a single event token into an AgentEvent.
81
+
82
+ Args:
83
+ token: Single event token (e.g., "T", "T[calc]", "C[quit|exit]")
84
+
85
+ Returns:
86
+ AgentEvent object
87
+
88
+ Raises:
89
+ ValueError: If token is invalid
90
+ """
91
+ # Check for bracket notation
92
+ bracket_match = re.match(r"^([A-Z])\[([^\]]+)\]$", token)
93
+
94
+ if bracket_match:
95
+ event_code = bracket_match.group(1)
96
+ param = bracket_match.group(2)
97
+
98
+ if event_code == "T":
99
+ # Specific tool: T[tool_name]
100
+ return AgentEvent(event_type=EventType.SPECIFIC_TOOL, tool_name=param)
101
+ elif event_code == "C":
102
+ # Content match: C[regex_pattern]
103
+ return AgentEvent(event_type=EventType.CONTENT_MATCH, content_pattern=param)
104
+ else:
105
+ raise ValueError(
106
+ f"Invalid event code with brackets: {event_code}. "
107
+ "Only T[tool] and C[pattern] are supported."
108
+ )
109
+
110
+ # Simple single-letter codes
111
+ event_map = {
112
+ "T": EventType.TOOL,
113
+ "A": EventType.AGENT_RESPONSE,
114
+ "L": EventType.LLM_RESPONSE,
115
+ "U": EventType.USER_RESPONSE,
116
+ "N": EventType.NO_RESPONSE,
117
+ "C": EventType.CONTENT_MATCH, # C without brackets matches any content
118
+ }
119
+
120
+ if token in event_map:
121
+ return AgentEvent(event_type=event_map[token])
122
+
123
+ # If not a single letter, could be a full event type name
124
+ token_upper = token.upper()
125
+ if token_upper == "TOOL":
126
+ return AgentEvent(event_type=EventType.TOOL)
127
+ elif token_upper == "AGENT":
128
+ return AgentEvent(event_type=EventType.AGENT_RESPONSE)
129
+ elif token_upper == "LLM":
130
+ return AgentEvent(event_type=EventType.LLM_RESPONSE)
131
+ elif token_upper == "USER":
132
+ return AgentEvent(event_type=EventType.USER_RESPONSE)
133
+ else:
134
+ raise ValueError(
135
+ f"Invalid event token: '{token}'. "
136
+ "Valid tokens are: T, A, L, U, N, C, or T[tool_name], C[pattern]"
137
+ )
138
+
139
+
140
+ def parse_done_sequences(
141
+ sequences: List[Union[str, DoneSequence]]
142
+ ) -> List[DoneSequence]:
143
+ """Parse a list of mixed string patterns and DoneSequence objects.
144
+
145
+ Args:
146
+ sequences: List containing strings and/or DoneSequence objects
147
+
148
+ Returns:
149
+ List of DoneSequence objects
150
+ """
151
+ return [parse_done_sequence(seq) for seq in sequences]
langroid/agent/task.py CHANGED
@@ -6,6 +6,7 @@ import logging
6
6
  import re
7
7
  import threading
8
8
  from collections import Counter, OrderedDict, deque
9
+ from enum import Enum
9
10
  from pathlib import Path
10
11
  from types import SimpleNamespace
11
12
  from typing import (
@@ -20,6 +21,7 @@ from typing import (
20
21
  Tuple,
21
22
  Type,
22
23
  TypeVar,
24
+ Union,
23
25
  cast,
24
26
  overload,
25
27
  )
@@ -69,6 +71,38 @@ def noop_fn(*args: List[Any], **kwargs: Dict[str, Any]) -> None:
69
71
  pass
70
72
 
71
73
 
74
+ class EventType(str, Enum):
75
+ """Types of events that can occur in a task"""
76
+
77
+ TOOL = "tool" # Any tool generated
78
+ SPECIFIC_TOOL = "specific_tool" # Specific tool by name
79
+ LLM_RESPONSE = "llm_response" # LLM generates response
80
+ AGENT_RESPONSE = "agent_response" # Agent responds
81
+ USER_RESPONSE = "user_response" # User responds
82
+ CONTENT_MATCH = "content_match" # Response matches pattern
83
+ NO_RESPONSE = "no_response" # No valid response from entity
84
+ CUSTOM = "custom" # Custom condition
85
+
86
+
87
+ class AgentEvent(BaseModel):
88
+ """Single event in a task sequence"""
89
+
90
+ event_type: EventType
91
+ tool_name: Optional[str] = None # For SPECIFIC_TOOL
92
+ content_pattern: Optional[str] = None # For CONTENT_MATCH (regex)
93
+ responder: Optional[str] = None # Specific responder name
94
+ # Optionally match only if the responder was specific entity/task
95
+ sender: Optional[str] = None # Entity name or Task name that sent the message
96
+
97
+
98
+ class DoneSequence(BaseModel):
99
+ """A sequence of events that triggers task completion"""
100
+
101
+ events: List[AgentEvent]
102
+ # Optional name for debugging
103
+ name: Optional[str] = None
104
+
105
+
72
106
  class TaskConfig(BaseModel):
73
107
  """Configuration for a Task. This is a container for any params that
74
108
  we didn't include in the task `__init__` method.
@@ -108,6 +142,9 @@ class TaskConfig(BaseModel):
108
142
  contains a Tool attempt by the LLM
109
143
  (including tools not handled by the agent).
110
144
  Default is False.
145
+ done_sequences (List[DoneSequence]): List of event sequences that trigger task
146
+ completion. Task is done if ANY sequence matches the recent event history.
147
+ Each sequence is checked against the message parent chain.
111
148
 
112
149
  """
113
150
 
@@ -120,6 +157,7 @@ class TaskConfig(BaseModel):
120
157
  allow_subtask_multi_oai_tools: bool = True
121
158
  recognize_string_signals: bool = True
122
159
  done_if_tool: bool = False
160
+ done_sequences: Optional[List[Union[str, DoneSequence]]] = None
123
161
 
124
162
 
125
163
  class Task:
@@ -257,6 +295,14 @@ class Task:
257
295
  set_parent_agent=noop_fn,
258
296
  )
259
297
  self.config = config
298
+ # Store parsed done sequences
299
+ self._parsed_done_sequences: Optional[List[DoneSequence]] = None
300
+ if self.config.done_sequences:
301
+ from .done_sequence_parser import parse_done_sequences
302
+
303
+ self._parsed_done_sequences = parse_done_sequences(
304
+ self.config.done_sequences
305
+ )
260
306
  # how to behave as a sub-task; can be overridden by `add_sub_task()`
261
307
  self.config_sub_task = copy.deepcopy(config)
262
308
  # counts of distinct pending messages in history,
@@ -360,6 +406,8 @@ class Task:
360
406
  self.single_round = single_round
361
407
  self.turns = -1 # no limit
362
408
  self.llm_delegate = llm_delegate
409
+ # Track last responder for done sequence checking
410
+ self._last_responder: Optional[Responder] = None
363
411
  if llm_delegate:
364
412
  if self.single_round:
365
413
  # 0: User instructs (delegating to LLM);
@@ -1276,6 +1324,9 @@ class Task:
1276
1324
 
1277
1325
  self._update_no_answer_vars(result)
1278
1326
 
1327
+ # Store the last responder for done sequence checking
1328
+ self._last_responder = r
1329
+
1279
1330
  # pending_sender is of type Responder,
1280
1331
  # i.e. it is either one of the agent's entities
1281
1332
  # OR a sub-task, that has produced a valid response.
@@ -1841,6 +1892,23 @@ class Task:
1841
1892
  ):
1842
1893
  return (True, StatusCode.DONE)
1843
1894
 
1895
+ # Check done sequences
1896
+ if self._parsed_done_sequences and result is not None:
1897
+ # Get the message chain from the current result
1898
+ msg_chain = self._get_message_chain(result)
1899
+
1900
+ # Use last responder if r not provided
1901
+ responder = r if r is not None else self._last_responder
1902
+
1903
+ # Check each sequence
1904
+ for sequence in self._parsed_done_sequences:
1905
+ if self._matches_sequence_with_current(
1906
+ msg_chain, sequence, result, responder
1907
+ ):
1908
+ seq_name = sequence.name or "unnamed"
1909
+ logger.info(f"Task {self.name} done: matched sequence '{seq_name}'")
1910
+ return (True, StatusCode.DONE)
1911
+
1844
1912
  allow_done_string = self.config.recognize_string_signals
1845
1913
  # An entity decided task is done, either via DoneTool,
1846
1914
  # or by explicitly saying DONE
@@ -2127,3 +2195,196 @@ class Task:
2127
2195
  return False, addressee, content_to_send
2128
2196
 
2129
2197
  return None, None, None
2198
+
2199
+ def _classify_event(
2200
+ self, msg: ChatDocument | None, responder: Responder | None
2201
+ ) -> Optional[AgentEvent]:
2202
+ """Classify a message into an AgentEvent for sequence matching."""
2203
+ if msg is None:
2204
+ return AgentEvent(event_type=EventType.NO_RESPONSE)
2205
+
2206
+ # Determine the event type based on responder and message content
2207
+ event_type = EventType.NO_RESPONSE
2208
+ tool_name = None
2209
+
2210
+ # Check if there are tool messages
2211
+ tool_messages = self.agent.try_get_tool_messages(msg, all_tools=True)
2212
+ if tool_messages:
2213
+ event_type = EventType.TOOL
2214
+ if len(tool_messages) == 1:
2215
+ tool_name = tool_messages[0].request
2216
+
2217
+ # Check responder type
2218
+ if responder == Entity.LLM and not tool_messages:
2219
+ event_type = EventType.LLM_RESPONSE
2220
+ elif responder == Entity.AGENT:
2221
+ event_type = EventType.AGENT_RESPONSE
2222
+ elif responder == Entity.USER:
2223
+ event_type = EventType.USER_RESPONSE
2224
+ elif isinstance(responder, Task):
2225
+ # For sub-task responses, check the sender in metadata
2226
+ if msg.metadata.sender == Entity.LLM:
2227
+ event_type = EventType.LLM_RESPONSE
2228
+ elif msg.metadata.sender == Entity.AGENT:
2229
+ event_type = EventType.AGENT_RESPONSE
2230
+ else:
2231
+ event_type = EventType.USER_RESPONSE
2232
+
2233
+ # Get sender name
2234
+ sender_name = None
2235
+ if isinstance(responder, Entity):
2236
+ sender_name = responder.value
2237
+ elif isinstance(responder, Task):
2238
+ sender_name = responder.name
2239
+
2240
+ return AgentEvent(
2241
+ event_type=event_type,
2242
+ tool_name=tool_name,
2243
+ sender=sender_name,
2244
+ )
2245
+
2246
+ def _get_message_chain(
2247
+ self, msg: ChatDocument | None, max_depth: Optional[int] = None
2248
+ ) -> List[ChatDocument]:
2249
+ """Get the chain of messages by following parent pointers."""
2250
+ if max_depth is None:
2251
+ # Get max depth needed from all sequences
2252
+ max_depth = 50 # default fallback
2253
+ if self._parsed_done_sequences:
2254
+ max_depth = max(len(seq.events) for seq in self._parsed_done_sequences)
2255
+
2256
+ chain = []
2257
+ current = msg
2258
+ depth = 0
2259
+
2260
+ while current is not None and depth < max_depth:
2261
+ chain.append(current)
2262
+ current = current.parent
2263
+ depth += 1
2264
+
2265
+ # Reverse to get chronological order (oldest first)
2266
+ return list(reversed(chain))
2267
+
2268
+ def _matches_event(self, actual: AgentEvent, expected: AgentEvent) -> bool:
2269
+ """Check if an actual event matches an expected event pattern."""
2270
+ # Check event type
2271
+ if expected.event_type == EventType.SPECIFIC_TOOL:
2272
+ if actual.event_type != EventType.TOOL:
2273
+ return False
2274
+ if expected.tool_name and actual.tool_name != expected.tool_name:
2275
+ return False
2276
+ elif actual.event_type != expected.event_type:
2277
+ return False
2278
+
2279
+ # Check sender if specified
2280
+ if expected.sender and actual.sender != expected.sender:
2281
+ return False
2282
+
2283
+ # TODO: Add content pattern matching for CONTENT_MATCH type
2284
+
2285
+ return True
2286
+
2287
+ def _matches_sequence(
2288
+ self, msg_chain: List[ChatDocument], sequence: DoneSequence
2289
+ ) -> bool:
2290
+ """Check if a message chain matches a done sequence.
2291
+
2292
+ We traverse the message chain and try to match the sequence events.
2293
+ The events don't have to be consecutive in the chain.
2294
+ """
2295
+ if not sequence.events:
2296
+ return False
2297
+
2298
+ # Convert messages to events
2299
+ events = []
2300
+ for i, msg in enumerate(msg_chain):
2301
+ # Determine responder from metadata or by checking previous message
2302
+ responder = None
2303
+ if msg.metadata.sender:
2304
+ responder = msg.metadata.sender
2305
+ elif msg.metadata.sender_name:
2306
+ # Could be a task name - keep as None for now since we can't resolve
2307
+ # the actual Task object from just the name
2308
+ responder = None
2309
+
2310
+ event = self._classify_event(msg, responder)
2311
+ if event:
2312
+ events.append(event)
2313
+
2314
+ # Try to match the sequence
2315
+ seq_idx = 0
2316
+ for event in events:
2317
+ if seq_idx >= len(sequence.events):
2318
+ break
2319
+
2320
+ expected = sequence.events[seq_idx]
2321
+ if self._matches_event(event, expected):
2322
+ seq_idx += 1
2323
+
2324
+ # Check if we matched the entire sequence
2325
+ return seq_idx == len(sequence.events)
2326
+
2327
+ def _matches_sequence_with_current(
2328
+ self,
2329
+ msg_chain: List[ChatDocument],
2330
+ sequence: DoneSequence,
2331
+ current_msg: ChatDocument,
2332
+ current_responder: Optional[Responder],
2333
+ ) -> bool:
2334
+ """Check if the message chain plus current message matches a done sequence.
2335
+
2336
+ Process messages in reverse order (newest first) and match against
2337
+ the sequence events in reverse order.
2338
+ """
2339
+ # Add current message to chain if not already there
2340
+ if not msg_chain or msg_chain[-1].id() != current_msg.id():
2341
+ msg_chain = msg_chain + [current_msg]
2342
+
2343
+ # If we don't have enough messages for the sequence, can't match
2344
+ if len(msg_chain) < len(sequence.events):
2345
+ return False
2346
+
2347
+ # Process in reverse order - start from the end of both lists
2348
+ seq_idx = len(sequence.events) - 1
2349
+ msg_idx = len(msg_chain) - 1
2350
+
2351
+ while seq_idx >= 0 and msg_idx >= 0:
2352
+ msg = msg_chain[msg_idx]
2353
+ expected = sequence.events[seq_idx]
2354
+
2355
+ # Determine responder for this message
2356
+ if msg_idx == len(msg_chain) - 1 and current_responder is not None:
2357
+ # For the last message, use the current responder
2358
+ responder = current_responder
2359
+ else:
2360
+ # For other messages, determine from metadata
2361
+ responder = msg.metadata.sender
2362
+
2363
+ # Classify the event
2364
+ event = self._classify_event(msg, responder)
2365
+ if not event:
2366
+ return False
2367
+
2368
+ # Check if it matches
2369
+ matched = False
2370
+
2371
+ # Special handling for CONTENT_MATCH
2372
+ if (
2373
+ expected.event_type == EventType.CONTENT_MATCH
2374
+ and expected.content_pattern
2375
+ ):
2376
+ if re.search(expected.content_pattern, msg.content, re.IGNORECASE):
2377
+ matched = True
2378
+ elif self._matches_event(event, expected):
2379
+ matched = True
2380
+
2381
+ if not matched:
2382
+ # Strict matching - no skipping allowed
2383
+ return False
2384
+ else:
2385
+ # Matched! Move to next expected event
2386
+ seq_idx -= 1
2387
+ msg_idx -= 1
2388
+
2389
+ # We matched if we've matched all events in the sequence
2390
+ return seq_idx < 0
@@ -1,6 +1,8 @@
1
1
  import asyncio
2
2
  import datetime
3
3
  import logging
4
+ from base64 import b64decode
5
+ from io import BytesIO
4
6
  from typing import Any, Dict, List, Optional, Tuple, Type, TypeAlias, cast
5
7
 
6
8
  from dotenv import load_dotenv
@@ -16,9 +18,20 @@ from mcp.client.session import (
16
18
  LoggingFnT,
17
19
  MessageHandlerFnT,
18
20
  )
19
- from mcp.types import CallToolResult, TextContent, Tool
21
+ from mcp.types import (
22
+ BlobResourceContents,
23
+ CallToolResult,
24
+ EmbeddedResource,
25
+ ImageContent,
26
+ TextContent,
27
+ TextResourceContents,
28
+ Tool,
29
+ )
20
30
 
31
+ from langroid.agent.base import Agent
32
+ from langroid.agent.chat_document import ChatDocument
21
33
  from langroid.agent.tool_message import ToolMessage
34
+ from langroid.parsing.file_attachment import FileAttachment
22
35
  from langroid.pydantic_v1 import AnyUrl, BaseModel, Field, create_model
23
36
 
24
37
  load_dotenv() # load environment variables from .env
@@ -39,6 +52,10 @@ class FastMCPClient:
39
52
  def __init__(
40
53
  self,
41
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,
42
59
  sampling_handler: SamplingHandler | None = None, # type: ignore
43
60
  roots: RootsList | RootsHandler | None = None, # type: ignore
44
61
  log_handler: LoggingFnT | None = None,
@@ -58,6 +75,10 @@ class FastMCPClient:
58
75
  self.log_handler = log_handler
59
76
  self.message_handler = message_handler
60
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
61
82
 
62
83
  async def __aenter__(self) -> "FastMCPClient":
63
84
  """Enter the async context manager and connect inner client."""
@@ -96,6 +117,19 @@ class FastMCPClient:
96
117
  self.client = None
97
118
  self._cm = None
98
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
+
99
133
  def _schema_to_field(
100
134
  self, name: str, schema: Dict[str, Any], prefix: str
101
135
  ) -> Tuple[Any, Any]:
@@ -151,7 +185,13 @@ class FastMCPClient:
151
185
  with the given `tool_name`.
152
186
  """
153
187
  if not self.client:
154
- raise RuntimeError("Client not initialized. Use async with FastMCPClient.")
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
+ )
155
195
  target = await self.get_mcp_tool_async(tool_name)
156
196
  if target is None:
157
197
  raise ValueError(f"No tool named {tool_name}")
@@ -209,36 +249,68 @@ class FastMCPClient:
209
249
  tool_model._renamed_fields = renamed # type: ignore[attr-defined]
210
250
 
211
251
  # 2) define an arg-free call_tool_async()
212
- async def call_tool_async(self: ToolMessage) -> Any:
252
+ async def call_tool_async(itself: ToolMessage) -> Any:
213
253
  from langroid.agent.tools.mcp.fastmcp_client import FastMCPClient
214
254
 
215
255
  # pack up the payload
216
- payload = self.dict(
217
- exclude=self.Config.schema_extra["exclude"].union(
256
+ payload = itself.dict(
257
+ exclude=itself.Config.schema_extra["exclude"].union(
218
258
  ["request", "purpose"]
219
259
  ),
220
260
  )
221
261
 
222
262
  # restore any renamed fields
223
- for orig, new in self.__class__._renamed_fields.items(): # type: ignore
263
+ for orig, new in itself.__class__._renamed_fields.items(): # type: ignore
224
264
  if new in payload:
225
265
  payload[orig] = payload.pop(new)
226
266
 
227
- client_cfg = getattr(self.__class__, "_client_config", None) # type: ignore
267
+ client_cfg = getattr(itself.__class__, "_client_config", None) # type: ignore
228
268
  if not client_cfg:
229
269
  # Fallback or error - ideally _client_config should always exist
230
- raise RuntimeError(f"Client config missing on {self.__class__}")
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
+
231
279
  # open a fresh client, call the tool, then close
232
280
  async with FastMCPClient(**client_cfg) as client: # type: ignore
233
- return await client.call_mcp_tool(self.request, payload)
281
+ return await client.call_mcp_tool(itself.request, payload)
234
282
 
235
283
  tool_model.call_tool_async = call_tool_async # type: ignore
236
284
 
237
285
  if not hasattr(tool_model, "handle_async"):
238
- # 3) define an arg-free handle_async() method
239
- # if the tool model doesn't already have one
240
- async def handle_async(self: ToolMessage) -> Any:
241
- return await self.call_tool_async() # type: ignore[attr-defined]
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
242
314
 
243
315
  # add the handle_async() method to the tool model
244
316
  tool_model.handle_async = handle_async # type: ignore
@@ -251,7 +323,13 @@ class FastMCPClient:
251
323
  handling nested schemas, with `handle_async` methods
252
324
  """
253
325
  if not self.client:
254
- raise RuntimeError("Client not initialized. Use async with FastMCPClient.")
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
+ )
255
333
  resp = await self.client.list_tools()
256
334
  return [await self.get_tool_async(t.name) for t in resp]
257
335
 
@@ -267,7 +345,13 @@ class FastMCPClient:
267
345
  The raw Tool object from the server, or None.
268
346
  """
269
347
  if not self.client:
270
- raise RuntimeError("Client not initialized. Use async with FastMCPClient.")
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
+ )
271
355
  resp: List[Tool] = await self.client.list_tools()
272
356
  return next((t for t in resp if t.name == name), None)
273
357
 
@@ -275,7 +359,7 @@ class FastMCPClient:
275
359
  self,
276
360
  tool_name: str,
277
361
  result: CallToolResult,
278
- ) -> List[str] | str | None:
362
+ ) -> Optional[str | tuple[str, list[FileAttachment]]]:
279
363
  if result.isError:
280
364
  # Log more detailed error information
281
365
  error_content = None
@@ -293,26 +377,41 @@ class FastMCPClient:
293
377
  )
294
378
  return f"ERROR: Tool call failed - {error_content}"
295
379
 
296
- has_nontext_results = any(
297
- not isinstance(item, TextContent) for item in result.content
298
- )
299
- if has_nontext_results:
300
- self.logger.warning(
301
- f"""
302
- MCP Tool {tool_name} returned non-text results,
303
- which will be skipped.
304
- """,
305
- )
306
- results = [
380
+ results_text = [
307
381
  item.text for item in result.content if isinstance(item, TextContent)
308
382
  ]
309
- if len(results) == 1:
310
- return results[0]
311
- return results
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
312
411
 
313
412
  async def call_mcp_tool(
314
413
  self, tool_name: str, arguments: Dict[str, Any]
315
- ) -> str | List[str] | None:
414
+ ) -> Optional[tuple[str, list[FileAttachment]]]:
316
415
  """Call an MCP tool with the given arguments.
317
416
 
318
417
  Args:
@@ -323,12 +422,23 @@ class FastMCPClient:
323
422
  The result of the tool call.
324
423
  """
325
424
  if not self.client:
326
- raise RuntimeError("Client not initialized. Use async with FastMCPClient.")
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
+ )
327
432
  result: CallToolResult = await self.client.session.call_tool(
328
433
  tool_name,
329
434
  arguments,
330
435
  )
331
- return self._convert_tool_result(tool_name, result)
436
+ results = self._convert_tool_result(tool_name, result)
437
+
438
+ if isinstance(results, str):
439
+ return results, []
440
+
441
+ return results
332
442
 
333
443
 
334
444
  # ==============================================================================
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langroid
3
- Version: 0.54.1
3
+ Version: 0.55.0
4
4
  Summary: Harness LLMs with Multi-Agent Programming
5
5
  Author-email: Prasad Chalasani <pchalasani@gmail.com>
6
6
  License: MIT
@@ -209,7 +209,7 @@ Description-Content-Type: text/markdown
209
209
  [![PyPI - Version](https://img.shields.io/pypi/v/langroid)](https://pypi.org/project/langroid/)
210
210
  [![Downloads](https://img.shields.io/pypi/dm/langroid)](https://pypi.org/project/langroid/)
211
211
  [![Pytest](https://github.com/langroid/langroid/actions/workflows/pytest.yml/badge.svg)](https://github.com/langroid/langroid/actions/workflows/pytest.yml)
212
- [![codecov](https://codecov.io/gh/langroid/langroid/branch/main/graph/badge.svg?token=H94BX5F0TE)](https://codecov.io/gh/langroid/langroid)
212
+ [![codecov](https://codecov.io/gh/langroid/langroid/graph/badge.svg)](https://codecov.io/gh/langroid/langroid)
213
213
  [![Multi-Architecture DockerHub](https://github.com/langroid/langroid/actions/workflows/docker-publish.yml/badge.svg)](https://github.com/langroid/langroid/actions/workflows/docker-publish.yml)
214
214
 
215
215
  [![Static Badge](https://img.shields.io/badge/Documentation-blue?link=https%3A%2F%2Flangroid.github.io%2Flangroid%2F&link=https%3A%2F%2Flangroid.github.io%2Flangroid%2F)](https://langroid.github.io/langroid)
@@ -3,12 +3,13 @@ langroid/exceptions.py,sha256=OPjece_8cwg94DLPcOGA1ddzy5bGh65pxzcHMnssTz8,2995
3
3
  langroid/mytypes.py,sha256=HIcYAqGeA9OK0Hlscym2FI5Oax9QFljDZoVgRlomhRk,4014
4
4
  langroid/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
5
  langroid/agent/__init__.py,sha256=ll0Cubd2DZ-fsCMl7e10hf9ZjFGKzphfBco396IKITY,786
6
- langroid/agent/base.py,sha256=9ZA2PhluFXReeqtGr7mdOUZZ7AXX6Eg_YymzdniUB-E,80181
6
+ langroid/agent/base.py,sha256=a45iqoWet2W60h4wUIOyHDYlotgS0asqIjfbONT4fZQ,85706
7
7
  langroid/agent/batch.py,sha256=wpE9RqCNDVDhAXkCB7wEqfCIEAi6qKcrhaZ-Zr9T4C0,21375
8
8
  langroid/agent/chat_agent.py,sha256=2HIYzYxkrGkRIS97ioKfIqjaW3RbX89M39LjzBobBEY,88381
9
9
  langroid/agent/chat_document.py,sha256=6O20Fp4QrquykaF2jFtwNHkvcoDte1LLwVZNk9mVH9c,18057
10
+ langroid/agent/done_sequence_parser.py,sha256=GiLTVtzzKb_YQcPpwqOajKu97RSUzZC6ae_ZagDmt_A,4425
10
11
  langroid/agent/openai_assistant.py,sha256=JkAcs02bIrgPNVvUWVR06VCthc5-ulla2QMBzux_q6o,34340
11
- langroid/agent/task.py,sha256=Ns9SoeMoAHDW7OEcPHJ22cgQKsHzWSt9UAWi9BFl4NM,91366
12
+ langroid/agent/task.py,sha256=1xjey94vQRDDmXnhYCVu5D6Nw-3dVUaf_bUmXzRKxIk,101213
12
13
  langroid/agent/tool_message.py,sha256=BhjP-_TfQ2tgxuY4Yo_JHLOwwt0mJ4BwjPnREvEY4vk,14744
13
14
  langroid/agent/xml_tool_message.py,sha256=oeBKnJNoGaKdtz39XoWGMTNlVyXew2MWH5lgtYeh8wQ,15496
14
15
  langroid/agent/callbacks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -56,7 +57,7 @@ langroid/agent/tools/segment_extract_tool.py,sha256=__srZ_VGYLVOdPrITUM8S0HpmX4q
56
57
  langroid/agent/tools/tavily_search_tool.py,sha256=soI-j0HdgVQLf09wRQScaEK4b5RpAX9C4cwOivRFWWI,1903
57
58
  langroid/agent/tools/mcp/__init__.py,sha256=DJNM0VeFnFS3pJKCyFGggT8JVjVu0rBzrGzasT1HaSM,387
58
59
  langroid/agent/tools/mcp/decorators.py,sha256=h7dterhsmvWJ8q4mp_OopmuG2DF71ty8cZwOyzdDZuk,1127
59
- langroid/agent/tools/mcp/fastmcp_client.py,sha256=WF3MhksDH2MzwXZF8cilMhux0hUmj6Z0dDdBYQMZwRs,18008
60
+ langroid/agent/tools/mcp/fastmcp_client.py,sha256=rxdNRinJoxFLbuTEAy7gVocC0jFRwcwDcoz9KAJN7sg,22068
60
61
  langroid/cachedb/__init__.py,sha256=G2KyNnk3Qkhv7OKyxTOnpsxfDycx3NY0O_wXkJlalNY,96
61
62
  langroid/cachedb/base.py,sha256=ztVjB1DtN6pLCujCWnR6xruHxwVj3XkYniRTYAKKqk0,1354
62
63
  langroid/cachedb/redis_cachedb.py,sha256=7kgnbf4b5CKsCrlL97mHWKvdvlLt8zgn7lc528jEpiE,5141
@@ -133,7 +134,7 @@ langroid/vector_store/pineconedb.py,sha256=otxXZNaBKb9f_H75HTaU3lMHiaR2NUp5MqwLZ
133
134
  langroid/vector_store/postgres.py,sha256=wHPtIi2qM4fhO4pMQr95pz1ZCe7dTb2hxl4VYspGZoA,16104
134
135
  langroid/vector_store/qdrantdb.py,sha256=O6dSBoDZ0jzfeVBd7LLvsXu083xs2fxXtPa9gGX3JX4,18443
135
136
  langroid/vector_store/weaviatedb.py,sha256=Yn8pg139gOy3zkaPfoTbMXEEBCiLiYa1MU5d_3UA1K4,11847
136
- langroid-0.54.1.dist-info/METADATA,sha256=uamTXuB82_ONKDncZCXu8Lz_Xu1VpL2DT56kaaFLbDY,65395
137
- langroid-0.54.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
138
- langroid-0.54.1.dist-info/licenses/LICENSE,sha256=EgVbvA6VSYgUlvC3RvPKehSg7MFaxWDsFuzLOsPPfJg,1065
139
- langroid-0.54.1.dist-info/RECORD,,
137
+ langroid-0.55.0.dist-info/METADATA,sha256=5RQdMjfiolUraCednhoHvcQVOaSHcOqxHj5e-JS3M6Q,65366
138
+ langroid-0.55.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
139
+ langroid-0.55.0.dist-info/licenses/LICENSE,sha256=EgVbvA6VSYgUlvC3RvPKehSg7MFaxWDsFuzLOsPPfJg,1065
140
+ langroid-0.55.0.dist-info/RECORD,,