langroid 0.23.3__py3-none-any.whl → 0.25.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
@@ -142,12 +142,19 @@ class Agent(ABC):
142
142
  self.llm_tools_handled: Set[str] = set()
143
143
  self.llm_tools_usable: Set[str] = set()
144
144
  self.llm_tools_known: Set[str] = set() # all known tools, handled/used or not
145
+ # Indicates which tool-names are allowed to be inferred when
146
+ # the LLM "forgets" to include the request field in its
147
+ # tool-call.
148
+ self.enabled_requests_for_inference: Optional[Set[str]] = (
149
+ None # If None, we allow all
150
+ )
145
151
  self.interactive: bool = True # may be modified by Task wrapper
146
152
  self.token_stats_str = ""
147
153
  self.default_human_response: Optional[str] = None
148
154
  self._indent = ""
149
155
  self.llm = LanguageModel.create(config.llm)
150
156
  self.vecdb = VectorStore.create(config.vecdb) if config.vecdb else None
157
+ self.tool_error = False
151
158
  if config.parsing is not None and self.config.llm is not None:
152
159
  # token_encoding_model is used to obtain the tokenizer,
153
160
  # so in case it's an OpenAI model, we ensure that the tokenizer
@@ -168,6 +175,7 @@ class Agent(ABC):
168
175
  show_llm_response=noop_fn,
169
176
  show_agent_response=noop_fn,
170
177
  get_user_response=None,
178
+ get_user_response_async=None,
171
179
  get_last_step=noop_fn,
172
180
  set_parent_agent=noop_fn,
173
181
  show_error_message=noop_fn,
@@ -315,6 +323,52 @@ class Agent(ABC):
315
323
  lambda msg: message_class.handle_message_fallback(self, msg),
316
324
  )
317
325
 
326
+ async_tool_name = f"{tool}_async"
327
+ if (
328
+ hasattr(message_class, "handle_async")
329
+ and inspect.isfunction(message_class.handle_async)
330
+ and not hasattr(self, async_tool_name)
331
+ ):
332
+ has_chat_doc_arg = (
333
+ len(inspect.signature(message_class.handle_async).parameters) > 1
334
+ )
335
+
336
+ if has_chat_doc_arg:
337
+
338
+ @no_type_check
339
+ async def handler(obj, chat_doc):
340
+ return await obj.handle_async(chat_doc)
341
+
342
+ else:
343
+
344
+ @no_type_check
345
+ async def handler(obj):
346
+ return await obj.handle_async()
347
+
348
+ setattr(self, async_tool_name, handler)
349
+ elif (
350
+ hasattr(message_class, "response_async")
351
+ and inspect.isfunction(message_class.response_async)
352
+ and not hasattr(self, async_tool_name)
353
+ ):
354
+ has_chat_doc_arg = (
355
+ len(inspect.signature(message_class.response_async).parameters) > 2
356
+ )
357
+
358
+ if has_chat_doc_arg:
359
+
360
+ @no_type_check
361
+ async def handler(obj, chat_doc):
362
+ return await obj.response_async(self, chat_doc)
363
+
364
+ else:
365
+
366
+ @no_type_check
367
+ async def handler(obj):
368
+ return await obj.response_async(self)
369
+
370
+ setattr(self, async_tool_name, handler)
371
+
318
372
  return [tool]
319
373
 
320
374
  def enable_message_handling(
@@ -386,32 +440,14 @@ class Agent(ABC):
386
440
  recipient=recipient,
387
441
  )
388
442
 
389
- async def agent_response_async(
443
+ def _agent_response_final(
390
444
  self,
391
- msg: Optional[str | ChatDocument] = None,
392
- ) -> Optional[ChatDocument]:
393
- return self.agent_response(msg)
394
-
395
- def agent_response(
396
- self,
397
- msg: Optional[str | ChatDocument] = None,
445
+ msg: Optional[str | ChatDocument],
446
+ results: Optional[str | OrderedDict[str, str] | ChatDocument],
398
447
  ) -> Optional[ChatDocument]:
399
448
  """
400
- Response from the "agent itself", typically (but not only)
401
- used to handle LLM's "tool message" or `function_call`
402
- (e.g. OpenAI `function_call`).
403
- Args:
404
- msg (str|ChatDocument): the input to respond to: if msg is a string,
405
- and it contains a valid JSON-structured "tool message", or
406
- if msg is a ChatDocument, and it contains a `function_call`.
407
- Returns:
408
- Optional[ChatDocument]: the response, packaged as a ChatDocument
409
-
449
+ Convert results to final response.
410
450
  """
411
- if msg is None:
412
- return None
413
-
414
- results = self.handle_message(msg)
415
451
  if results is None:
416
452
  return None
417
453
  if not settings.quiet:
@@ -431,7 +467,7 @@ class Agent(ABC):
431
467
  if isinstance(results, ChatDocument):
432
468
  # Preserve trail of tool_ids for OpenAI Assistant fn-calls
433
469
  results.metadata.tool_ids = (
434
- [] if isinstance(msg, str) else msg.metadata.tool_ids
470
+ [] if msg is None or isinstance(msg, str) else msg.metadata.tool_ids
435
471
  )
436
472
  return results
437
473
  sender_name = self.config.name
@@ -454,10 +490,49 @@ class Agent(ABC):
454
490
  sender_name=sender_name,
455
491
  oai_tool_id=oai_tool_id,
456
492
  # preserve trail of tool_ids for OpenAI Assistant fn-calls
457
- tool_ids=[] if isinstance(msg, str) else msg.metadata.tool_ids,
493
+ tool_ids=(
494
+ [] if msg is None or isinstance(msg, str) else msg.metadata.tool_ids
495
+ ),
458
496
  ),
459
497
  )
460
498
 
499
+ async def agent_response_async(
500
+ self,
501
+ msg: Optional[str | ChatDocument] = None,
502
+ ) -> Optional[ChatDocument]:
503
+ """
504
+ Asynch version of `agent_response`. See there for details.
505
+ """
506
+ if msg is None:
507
+ return None
508
+
509
+ results = await self.handle_message_async(msg)
510
+
511
+ return self._agent_response_final(msg, results)
512
+
513
+ def agent_response(
514
+ self,
515
+ msg: Optional[str | ChatDocument] = None,
516
+ ) -> Optional[ChatDocument]:
517
+ """
518
+ Response from the "agent itself", typically (but not only)
519
+ used to handle LLM's "tool message" or `function_call`
520
+ (e.g. OpenAI `function_call`).
521
+ Args:
522
+ msg (str|ChatDocument): the input to respond to: if msg is a string,
523
+ and it contains a valid JSON-structured "tool message", or
524
+ if msg is a ChatDocument, and it contains a `function_call`.
525
+ Returns:
526
+ Optional[ChatDocument]: the response, packaged as a ChatDocument
527
+
528
+ """
529
+ if msg is None:
530
+ return None
531
+
532
+ results = self.handle_message(msg)
533
+
534
+ return self._agent_response_final(msg, results)
535
+
461
536
  def process_tool_results(
462
537
  self,
463
538
  results: str,
@@ -618,60 +693,46 @@ class Agent(ABC):
618
693
  recipient=recipient,
619
694
  )
620
695
 
621
- async def user_response_async(
622
- self,
623
- msg: Optional[str | ChatDocument] = None,
624
- ) -> Optional[ChatDocument]:
625
- return self.user_response(msg)
626
-
627
- def user_response(
628
- self,
629
- msg: Optional[str | ChatDocument] = None,
630
- ) -> Optional[ChatDocument]:
696
+ def user_can_respond(self, msg: Optional[str | ChatDocument] = None) -> bool:
631
697
  """
632
- Get user response to current message. Could allow (human) user to intervene
633
- with an actual answer, or quit using "q" or "x"
698
+ Whether the user can respond to a message.
634
699
 
635
700
  Args:
636
701
  msg (str|ChatDocument): the string to respond to.
637
702
 
638
703
  Returns:
639
- (str) User response, packaged as a ChatDocument
640
704
 
641
705
  """
642
-
643
706
  # When msg explicitly addressed to user, this means an actual human response
644
707
  # is being sought.
645
708
  need_human_response = (
646
709
  isinstance(msg, ChatDocument) and msg.metadata.recipient == Entity.USER
647
710
  )
648
- default_user_msg = (
649
- (self.default_human_response or "null") if need_human_response else ""
650
- )
651
711
 
652
712
  if not self.interactive and not need_human_response:
653
- return None
654
- elif self.default_human_response is not None:
655
- user_msg = self.default_human_response
656
- else:
657
- if self.callbacks.get_user_response is not None:
658
- # ask user with empty prompt: no need for prompt
659
- # since user has seen the conversation so far.
660
- # But non-empty prompt can be useful when Agent
661
- # uses a tool that requires user input, or in other scenarios.
662
- user_msg = self.callbacks.get_user_response(prompt="")
663
- else:
664
- user_msg = Prompt.ask(
665
- f"[blue]{self.indent}"
666
- + self.config.human_prompt
667
- + f"\n{self.indent}"
668
- ).strip()
713
+ return False
714
+
715
+ return True
716
+
717
+ def _user_response_final(
718
+ self, msg: Optional[str | ChatDocument], user_msg: str
719
+ ) -> Optional[ChatDocument]:
720
+ """
721
+ Convert user_msg to final response.
722
+ """
723
+ if not user_msg:
724
+ need_human_response = (
725
+ isinstance(msg, ChatDocument) and msg.metadata.recipient == Entity.USER
726
+ )
727
+ user_msg = (
728
+ (self.default_human_response or "null") if need_human_response else ""
729
+ )
730
+ user_msg = user_msg.strip()
669
731
 
670
732
  tool_ids = []
671
733
  if msg is not None and isinstance(msg, ChatDocument):
672
734
  tool_ids = msg.metadata.tool_ids
673
735
 
674
- user_msg = user_msg.strip() or default_user_msg.strip()
675
736
  # only return non-None result if user_msg not empty
676
737
  if not user_msg:
677
738
  return None
@@ -693,6 +754,72 @@ class Agent(ABC):
693
754
  ),
694
755
  )
695
756
 
757
+ async def user_response_async(
758
+ self,
759
+ msg: Optional[str | ChatDocument] = None,
760
+ ) -> Optional[ChatDocument]:
761
+ """
762
+ Asynch version of `user_response`. See there for details.
763
+ """
764
+ if not self.user_can_respond(msg):
765
+ return None
766
+
767
+ if self.default_human_response is not None:
768
+ user_msg = self.default_human_response
769
+ else:
770
+ if (
771
+ self.callbacks.get_user_response_async is not None
772
+ and self.callbacks.get_user_response_async is not async_noop_fn
773
+ ):
774
+ user_msg = await self.callbacks.get_user_response_async(prompt="")
775
+ elif self.callbacks.get_user_response is not None:
776
+ user_msg = self.callbacks.get_user_response(prompt="")
777
+ else:
778
+ user_msg = Prompt.ask(
779
+ f"[blue]{self.indent}"
780
+ + self.config.human_prompt
781
+ + f"\n{self.indent}"
782
+ )
783
+
784
+ return self._user_response_final(msg, user_msg)
785
+
786
+ def user_response(
787
+ self,
788
+ msg: Optional[str | ChatDocument] = None,
789
+ ) -> Optional[ChatDocument]:
790
+ """
791
+ Get user response to current message. Could allow (human) user to intervene
792
+ with an actual answer, or quit using "q" or "x"
793
+
794
+ Args:
795
+ msg (str|ChatDocument): the string to respond to.
796
+
797
+ Returns:
798
+ (str) User response, packaged as a ChatDocument
799
+
800
+ """
801
+
802
+ if not self.user_can_respond(msg):
803
+ return None
804
+
805
+ if self.default_human_response is not None:
806
+ user_msg = self.default_human_response
807
+ else:
808
+ if self.callbacks.get_user_response is not None:
809
+ # ask user with empty prompt: no need for prompt
810
+ # since user has seen the conversation so far.
811
+ # But non-empty prompt can be useful when Agent
812
+ # uses a tool that requires user input, or in other scenarios.
813
+ user_msg = self.callbacks.get_user_response(prompt="")
814
+ else:
815
+ user_msg = Prompt.ask(
816
+ f"[blue]{self.indent}"
817
+ + self.config.human_prompt
818
+ + f"\n{self.indent}"
819
+ )
820
+
821
+ return self._user_response_final(msg, user_msg)
822
+
696
823
  @no_type_check
697
824
  def llm_can_respond(self, message: Optional[str | ChatDocument] = None) -> bool:
698
825
  """
@@ -784,7 +911,7 @@ class Agent(ABC):
784
911
  f"""
785
912
  Requested output length has been shortened to {output_len}
786
913
  so that the total length of Prompt + Output is less than
787
- the completion context length of the LLM.
914
+ the completion context length of the LLM.
788
915
  """
789
916
  )
790
917
 
@@ -858,7 +985,7 @@ class Agent(ABC):
858
985
  f"""
859
986
  Requested output length has been shortened to {output_len}
860
987
  so that the total length of Prompt + Output is less than
861
- the completion context length of the LLM.
988
+ the completion context length of the LLM.
862
989
  """
863
990
  )
864
991
  if self.llm.get_stream() and not settings.quiet:
@@ -1040,6 +1167,7 @@ class Agent(ABC):
1040
1167
  Returns:
1041
1168
  List[ToolMessage]: list of ToolMessage objects
1042
1169
  """
1170
+ self.tool_error = False
1043
1171
  substrings = XMLToolMessage.find_candidates(input_str)
1044
1172
  is_json = False
1045
1173
  if len(substrings) == 0:
@@ -1049,7 +1177,11 @@ class Agent(ABC):
1049
1177
  return []
1050
1178
 
1051
1179
  results = [self._get_one_tool_message(j, is_json) for j in substrings]
1052
- return [r for r in results if r is not None]
1180
+ valid_results = [r for r in results if r is not None]
1181
+ # If any tool is correctly formed we do not set the flag
1182
+ if len(valid_results) > 0:
1183
+ self.tool_error = False
1184
+ return valid_results
1053
1185
 
1054
1186
  def get_function_call_class(self, msg: ChatDocument) -> Optional[ToolMessage]:
1055
1187
  """
@@ -1063,7 +1195,7 @@ class Agent(ABC):
1063
1195
  if tool_name not in self.llm_tools_handled:
1064
1196
  logger.warning(
1065
1197
  f"""
1066
- The function_call '{tool_name}' is not handled
1198
+ The function_call '{tool_name}' is not handled
1067
1199
  by the agent named '{self.config.name}'!
1068
1200
  If you intended this agent to handle this function_call,
1069
1201
  either the fn-call name is incorrectly generated by the LLM,
@@ -1071,7 +1203,10 @@ class Agent(ABC):
1071
1203
  or you need to enable this agent to handle this fn-call.
1072
1204
  """
1073
1205
  )
1206
+ if tool_name not in self.all_llm_tools_known:
1207
+ self.tool_error = True
1074
1208
  return None
1209
+ self.tool_error = False
1075
1210
  tool_class = self.llm_tools_map[tool_name]
1076
1211
  tool_msg.update(dict(request=tool_name))
1077
1212
  tool = tool_class.parse_obj(tool_msg)
@@ -1086,6 +1221,7 @@ class Agent(ABC):
1086
1221
  if msg.oai_tool_calls is None:
1087
1222
  return []
1088
1223
  tools = []
1224
+ all_errors = True
1089
1225
  for tc in msg.oai_tool_calls:
1090
1226
  if tc.function is None:
1091
1227
  continue
@@ -1094,7 +1230,7 @@ class Agent(ABC):
1094
1230
  if tool_name not in self.llm_tools_handled:
1095
1231
  logger.warning(
1096
1232
  f"""
1097
- The tool_call '{tool_name}' is not handled
1233
+ The tool_call '{tool_name}' is not handled
1098
1234
  by the agent named '{self.config.name}'!
1099
1235
  If you intended this agent to handle this function_call,
1100
1236
  either the fn-call name is incorrectly generated by the LLM,
@@ -1103,11 +1239,14 @@ class Agent(ABC):
1103
1239
  """
1104
1240
  )
1105
1241
  continue
1242
+ all_errors = False
1106
1243
  tool_class = self.llm_tools_map[tool_name]
1107
1244
  tool_msg.update(dict(request=tool_name))
1108
1245
  tool = tool_class.parse_obj(tool_msg)
1109
1246
  tool.id = tc.id or ""
1110
1247
  tools.append(tool)
1248
+ # When no tool is valid, set the recovery flag
1249
+ self.tool_error = all_errors
1111
1250
  return tools
1112
1251
 
1113
1252
  def tool_validation_error(self, ve: ValidationError) -> str:
@@ -1126,65 +1265,18 @@ class Agent(ABC):
1126
1265
  [f"{e['loc']}: {e['msg']}" for e in ve.errors() if "loc" in e]
1127
1266
  )
1128
1267
  return f"""
1129
- There were one or more errors in your attempt to use the
1130
- TOOL or function_call named '{tool_name}':
1268
+ There were one or more errors in your attempt to use the
1269
+ TOOL or function_call named '{tool_name}':
1131
1270
  {bad_field_errors}
1132
1271
  Please write your message again, correcting the errors.
1133
1272
  """
1134
1273
 
1135
- def handle_message(
1136
- self, msg: str | ChatDocument
1137
- ) -> None | str | OrderedDict[str, str] | ChatDocument:
1274
+ def _get_multiple_orch_tool_errs(
1275
+ self, tools: List[ToolMessage]
1276
+ ) -> List[str | ChatDocument | None]:
1138
1277
  """
1139
- Handle a "tool" message either a string containing one or more
1140
- valid "tool" JSON substrings, or a
1141
- ChatDocument containing a `function_call` attribute.
1142
- Handle with the corresponding handler method, and return
1143
- the results as a combined string.
1144
-
1145
- Args:
1146
- msg (str | ChatDocument): The string or ChatDocument to handle
1147
-
1148
- Returns:
1149
- The result of the handler method can be:
1150
- - None if no tools successfully handled, or no tools present
1151
- - str if langroid-native JSON tools were handled, and results concatenated,
1152
- OR there's a SINGLE OpenAI tool-call.
1153
- (We do this so the common scenario of a single tool/fn-call
1154
- has a simple behavior).
1155
- - Dict[str, str] if multiple OpenAI tool-calls were handled
1156
- (dict is an id->result map)
1157
- - ChatDocument if a handler returned a ChatDocument, intended to be the
1158
- final response of the `agent_response` method.
1278
+ Return error document if the message contains multiple orchestration tools
1159
1279
  """
1160
- try:
1161
- tools = self.get_tool_messages(msg)
1162
- tools = [t for t in tools if self._tool_recipient_match(t)]
1163
- except ValidationError as ve:
1164
- # correct tool name but bad fields
1165
- return self.tool_validation_error(ve)
1166
- except XMLException as xe: # from XMLToolMessage parsing
1167
- return str(xe)
1168
- except ValueError:
1169
- # invalid tool name
1170
- # We return None since returning "invalid tool name" would
1171
- # be considered a valid result in task loop, and would be treated
1172
- # as a response to the tool message even though the tool was not intended
1173
- # for this agent.
1174
- return None
1175
- if len(tools) > 1 and not self.config.allow_multiple_tools:
1176
- return self.to_ChatDocument("ERROR: Use ONE tool at a time!")
1177
- if len(tools) == 0:
1178
- fallback_result = self.handle_message_fallback(msg)
1179
- if fallback_result is None:
1180
- return None
1181
- return self.to_ChatDocument(
1182
- fallback_result,
1183
- chat_doc=msg if isinstance(msg, ChatDocument) else None,
1184
- )
1185
- has_ids = all([t.id != "" for t in tools])
1186
- chat_doc = msg if isinstance(msg, ChatDocument) else None
1187
-
1188
1280
  # check whether there are multiple orchestration-tools (e.g. DoneTool etc),
1189
1281
  # in which case set result to error-string since we don't yet support
1190
1282
  # multi-tools with one or more orch tools.
@@ -1211,20 +1303,24 @@ class Agent(ABC):
1211
1303
  )
1212
1304
 
1213
1305
  has_orch = any(isinstance(t, ORCHESTRATION_TOOLS) for t in tools)
1214
- results: List[str | ChatDocument | None]
1215
1306
  if has_orch and len(tools) > 1:
1216
1307
  err_str = "ERROR: Use ONE tool at a time!"
1217
- results = [err_str for _ in tools]
1218
- else:
1219
- results = [self.handle_tool_message(t, chat_doc=chat_doc) for t in tools]
1220
- # if there's a solitary ChatDocument|str result, return it as is
1221
- if len(results) == 1 and isinstance(results[0], (str, ChatDocument)):
1222
- return results[0]
1223
- # extract content from ChatDocument results so we have all str|None
1224
- results = [r.content if isinstance(r, ChatDocument) else r for r in results]
1308
+ return [err_str for _ in tools]
1309
+
1310
+ return []
1311
+
1312
+ def _handle_message_final(
1313
+ self, tools: List[ToolMessage], results: List[str | ChatDocument | None]
1314
+ ) -> None | str | OrderedDict[str, str] | ChatDocument:
1315
+ """
1316
+ Convert results to final response
1317
+ """
1318
+ # extract content from ChatDocument results so we have all str|None
1319
+ results = [r.content if isinstance(r, ChatDocument) else r for r in results]
1225
1320
 
1226
- # now all results are str|None
1227
1321
  tool_names = [t.default_value("request") for t in tools]
1322
+
1323
+ has_ids = all([t.id != "" for t in tools])
1228
1324
  if has_ids:
1229
1325
  id2result = OrderedDict(
1230
1326
  (t.id, r)
@@ -1259,6 +1355,117 @@ class Agent(ABC):
1259
1355
  final = "\n\n".join(str_results)
1260
1356
  return final
1261
1357
 
1358
+ async def handle_message_async(
1359
+ self, msg: str | ChatDocument
1360
+ ) -> None | str | OrderedDict[str, str] | ChatDocument:
1361
+ """
1362
+ Asynch version of `handle_message`. See there for details.
1363
+ """
1364
+ try:
1365
+ tools = self.get_tool_messages(msg)
1366
+ tools = [t for t in tools if self._tool_recipient_match(t)]
1367
+ except ValidationError as ve:
1368
+ # correct tool name but bad fields
1369
+ return self.tool_validation_error(ve)
1370
+ except XMLException as xe: # from XMLToolMessage parsing
1371
+ return str(xe)
1372
+ except ValueError:
1373
+ # invalid tool name
1374
+ # We return None since returning "invalid tool name" would
1375
+ # be considered a valid result in task loop, and would be treated
1376
+ # as a response to the tool message even though the tool was not intended
1377
+ # for this agent.
1378
+ return None
1379
+ if len(tools) > 1 and not self.config.allow_multiple_tools:
1380
+ return self.to_ChatDocument("ERROR: Use ONE tool at a time!")
1381
+ if len(tools) == 0:
1382
+ fallback_result = self.handle_message_fallback(msg)
1383
+ if fallback_result is None:
1384
+ return None
1385
+ return self.to_ChatDocument(
1386
+ fallback_result,
1387
+ chat_doc=msg if isinstance(msg, ChatDocument) else None,
1388
+ )
1389
+ chat_doc = msg if isinstance(msg, ChatDocument) else None
1390
+
1391
+ results = self._get_multiple_orch_tool_errs(tools)
1392
+ if not results:
1393
+ results = [
1394
+ await self.handle_tool_message_async(t, chat_doc=chat_doc)
1395
+ for t in tools
1396
+ ]
1397
+ # if there's a solitary ChatDocument|str result, return it as is
1398
+ if len(results) == 1 and isinstance(results[0], (str, ChatDocument)):
1399
+ return results[0]
1400
+
1401
+ return self._handle_message_final(tools, results)
1402
+
1403
+ def handle_message(
1404
+ self, msg: str | ChatDocument
1405
+ ) -> None | str | OrderedDict[str, str] | ChatDocument:
1406
+ """
1407
+ Handle a "tool" message either a string containing one or more
1408
+ valid "tool" JSON substrings, or a
1409
+ ChatDocument containing a `function_call` attribute.
1410
+ Handle with the corresponding handler method, and return
1411
+ the results as a combined string.
1412
+
1413
+ Args:
1414
+ msg (str | ChatDocument): The string or ChatDocument to handle
1415
+
1416
+ Returns:
1417
+ The result of the handler method can be:
1418
+ - None if no tools successfully handled, or no tools present
1419
+ - str if langroid-native JSON tools were handled, and results concatenated,
1420
+ OR there's a SINGLE OpenAI tool-call.
1421
+ (We do this so the common scenario of a single tool/fn-call
1422
+ has a simple behavior).
1423
+ - Dict[str, str] if multiple OpenAI tool-calls were handled
1424
+ (dict is an id->result map)
1425
+ - ChatDocument if a handler returned a ChatDocument, intended to be the
1426
+ final response of the `agent_response` method.
1427
+ """
1428
+ try:
1429
+ tools = self.get_tool_messages(msg)
1430
+ tools = [t for t in tools if self._tool_recipient_match(t)]
1431
+ except ValidationError as ve:
1432
+ # correct tool name but bad fields
1433
+ return self.tool_validation_error(ve)
1434
+ except XMLException as xe: # from XMLToolMessage parsing
1435
+ return str(xe)
1436
+ except ValueError:
1437
+ # invalid tool name
1438
+ # We return None since returning "invalid tool name" would
1439
+ # be considered a valid result in task loop, and would be treated
1440
+ # as a response to the tool message even though the tool was not intended
1441
+ # for this agent.
1442
+ return None
1443
+ if len(tools) > 1 and not self.config.allow_multiple_tools:
1444
+ return self.to_ChatDocument("ERROR: Use ONE tool at a time!")
1445
+ if len(tools) == 0:
1446
+ fallback_result = self.handle_message_fallback(msg)
1447
+ if fallback_result is None:
1448
+ return None
1449
+ return self.to_ChatDocument(
1450
+ fallback_result,
1451
+ chat_doc=msg if isinstance(msg, ChatDocument) else None,
1452
+ )
1453
+ chat_doc = msg if isinstance(msg, ChatDocument) else None
1454
+
1455
+ results = self._get_multiple_orch_tool_errs(tools)
1456
+ if not results:
1457
+ results = [self.handle_tool_message(t, chat_doc=chat_doc) for t in tools]
1458
+ # if there's a solitary ChatDocument|str result, return it as is
1459
+ if len(results) == 1 and isinstance(results[0], (str, ChatDocument)):
1460
+ return results[0]
1461
+
1462
+ return self._handle_message_final(tools, results)
1463
+
1464
+ @property
1465
+ def all_llm_tools_known(self) -> set[str]:
1466
+ """All known tools; this may extend self.llm_tools_known."""
1467
+ return self.llm_tools_known
1468
+
1262
1469
  def handle_message_fallback(self, msg: str | ChatDocument) -> Any:
1263
1470
  """
1264
1471
  Fallback method for the "no-tools" scenario.
@@ -1278,7 +1485,7 @@ class Agent(ABC):
1278
1485
  ) -> Optional[ToolMessage]:
1279
1486
  """
1280
1487
  Parse the tool_candidate_str into ANY ToolMessage KNOWN to agent --
1281
- This includes non-used/handled tools, i.e. any tool in self.llm_tools_known.
1488
+ This includes non-used/handled tools, i.e. any tool in self.all_llm_tools_known.
1282
1489
  The exception to this is below where we try our best to infer the tool
1283
1490
  when the LLM has "forgotten" to include the "request" field in the tool str ---
1284
1491
  in this case we ONLY look at the possible set of HANDLED tools, i.e.
@@ -1311,6 +1518,7 @@ class Agent(ABC):
1311
1518
  # }
1312
1519
 
1313
1520
  if not isinstance(maybe_tool_dict, dict):
1521
+ self.tool_error = True
1314
1522
  return None
1315
1523
 
1316
1524
  properties = maybe_tool_dict.get("properties")
@@ -1318,7 +1526,14 @@ class Agent(ABC):
1318
1526
  maybe_tool_dict = properties
1319
1527
  request = maybe_tool_dict.get("request")
1320
1528
  if request is None:
1321
- possible = [self.llm_tools_map[r] for r in self.llm_tools_handled]
1529
+ if self.enabled_requests_for_inference is None:
1530
+ possible = [self.llm_tools_map[r] for r in self.llm_tools_handled]
1531
+ else:
1532
+ allowable = self.enabled_requests_for_inference.intersection(
1533
+ self.llm_tools_handled
1534
+ )
1535
+ possible = [self.llm_tools_map[r] for r in allowable]
1536
+
1322
1537
  default_keys = set(ToolMessage.__fields__.keys())
1323
1538
  request_keys = set(maybe_tool_dict.keys())
1324
1539
 
@@ -1351,19 +1566,23 @@ class Agent(ABC):
1351
1566
  if len(candidate_tools) == 1:
1352
1567
  return candidate_tools[0]
1353
1568
  else:
1569
+ self.tool_error = True
1354
1570
  return None
1355
1571
 
1356
- if not isinstance(request, str) or request not in self.llm_tools_known:
1572
+ if not isinstance(request, str) or request not in self.all_llm_tools_known:
1573
+ self.tool_error = True
1357
1574
  return None
1358
1575
 
1359
1576
  message_class = self.llm_tools_map.get(request)
1360
1577
  if message_class is None:
1361
1578
  logger.warning(f"No message class found for request '{request}'")
1579
+ self.tool_error = True
1362
1580
  return None
1363
1581
 
1364
1582
  try:
1365
1583
  message = message_class.parse_obj(maybe_tool_dict)
1366
1584
  except ValidationError as ve:
1585
+ self.tool_error = True
1367
1586
  raise ve
1368
1587
  return message
1369
1588
 
@@ -1474,7 +1693,6 @@ class Agent(ABC):
1474
1693
  value = tool.get_value_of_type(output_type)
1475
1694
  if value is not None:
1476
1695
  return cast(T, value)
1477
-
1478
1696
  return None
1479
1697
 
1480
1698
  def _maybe_truncate_result(
@@ -1511,6 +1729,37 @@ class Agent(ABC):
1511
1729
  ) + truncate_warning
1512
1730
  return result
1513
1731
 
1732
+ async def handle_tool_message_async(
1733
+ self,
1734
+ tool: ToolMessage,
1735
+ chat_doc: Optional[ChatDocument] = None,
1736
+ ) -> None | str | ChatDocument:
1737
+ """
1738
+ Asynch version of `handle_tool_message`. See there for details.
1739
+ """
1740
+ tool_name = tool.default_value("request")
1741
+ handler_method = getattr(self, tool_name + "_async", None)
1742
+ if handler_method is None:
1743
+ return self.handle_tool_message(tool, chat_doc=chat_doc)
1744
+ has_chat_doc_arg = (
1745
+ chat_doc is not None
1746
+ and "chat_doc" in inspect.signature(handler_method).parameters
1747
+ )
1748
+ try:
1749
+ if has_chat_doc_arg:
1750
+ maybe_result = await handler_method(tool, chat_doc=chat_doc)
1751
+ else:
1752
+ maybe_result = await handler_method(tool)
1753
+ result = self.to_ChatDocument(maybe_result, tool_name, chat_doc)
1754
+ except Exception as e:
1755
+ # raise the error here since we are sure it's
1756
+ # not a pydantic validation error,
1757
+ # which we check in `handle_message`
1758
+ raise e
1759
+ return self._maybe_truncate_result(
1760
+ result, tool._max_result_tokens
1761
+ ) # type: ignore
1762
+
1514
1763
  def handle_tool_message(
1515
1764
  self,
1516
1765
  tool: ToolMessage,