langroid 0.1.85__py3-none-any.whl → 0.1.219__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.
Files changed (107) hide show
  1. langroid/__init__.py +95 -0
  2. langroid/agent/__init__.py +40 -0
  3. langroid/agent/base.py +222 -91
  4. langroid/agent/batch.py +264 -0
  5. langroid/agent/callbacks/chainlit.py +608 -0
  6. langroid/agent/chat_agent.py +247 -101
  7. langroid/agent/chat_document.py +41 -4
  8. langroid/agent/openai_assistant.py +842 -0
  9. langroid/agent/special/__init__.py +50 -0
  10. langroid/agent/special/doc_chat_agent.py +837 -141
  11. langroid/agent/special/lance_doc_chat_agent.py +258 -0
  12. langroid/agent/special/lance_rag/__init__.py +9 -0
  13. langroid/agent/special/lance_rag/critic_agent.py +136 -0
  14. langroid/agent/special/lance_rag/lance_rag_task.py +80 -0
  15. langroid/agent/special/lance_rag/query_planner_agent.py +180 -0
  16. langroid/agent/special/lance_tools.py +44 -0
  17. langroid/agent/special/neo4j/__init__.py +0 -0
  18. langroid/agent/special/neo4j/csv_kg_chat.py +174 -0
  19. langroid/agent/special/neo4j/neo4j_chat_agent.py +370 -0
  20. langroid/agent/special/neo4j/utils/__init__.py +0 -0
  21. langroid/agent/special/neo4j/utils/system_message.py +46 -0
  22. langroid/agent/special/relevance_extractor_agent.py +127 -0
  23. langroid/agent/special/retriever_agent.py +32 -198
  24. langroid/agent/special/sql/__init__.py +11 -0
  25. langroid/agent/special/sql/sql_chat_agent.py +47 -23
  26. langroid/agent/special/sql/utils/__init__.py +22 -0
  27. langroid/agent/special/sql/utils/description_extractors.py +95 -46
  28. langroid/agent/special/sql/utils/populate_metadata.py +28 -21
  29. langroid/agent/special/table_chat_agent.py +43 -9
  30. langroid/agent/task.py +475 -122
  31. langroid/agent/tool_message.py +75 -13
  32. langroid/agent/tools/__init__.py +13 -0
  33. langroid/agent/tools/duckduckgo_search_tool.py +66 -0
  34. langroid/agent/tools/google_search_tool.py +11 -0
  35. langroid/agent/tools/metaphor_search_tool.py +67 -0
  36. langroid/agent/tools/recipient_tool.py +16 -29
  37. langroid/agent/tools/run_python_code.py +60 -0
  38. langroid/agent/tools/sciphi_search_rag_tool.py +79 -0
  39. langroid/agent/tools/segment_extract_tool.py +36 -0
  40. langroid/cachedb/__init__.py +9 -0
  41. langroid/cachedb/base.py +22 -2
  42. langroid/cachedb/momento_cachedb.py +26 -2
  43. langroid/cachedb/redis_cachedb.py +78 -11
  44. langroid/embedding_models/__init__.py +34 -0
  45. langroid/embedding_models/base.py +21 -2
  46. langroid/embedding_models/models.py +120 -18
  47. langroid/embedding_models/protoc/embeddings.proto +19 -0
  48. langroid/embedding_models/protoc/embeddings_pb2.py +33 -0
  49. langroid/embedding_models/protoc/embeddings_pb2.pyi +50 -0
  50. langroid/embedding_models/protoc/embeddings_pb2_grpc.py +79 -0
  51. langroid/embedding_models/remote_embeds.py +153 -0
  52. langroid/language_models/__init__.py +45 -0
  53. langroid/language_models/azure_openai.py +80 -27
  54. langroid/language_models/base.py +117 -12
  55. langroid/language_models/config.py +5 -0
  56. langroid/language_models/openai_assistants.py +3 -0
  57. langroid/language_models/openai_gpt.py +558 -174
  58. langroid/language_models/prompt_formatter/__init__.py +15 -0
  59. langroid/language_models/prompt_formatter/base.py +4 -6
  60. langroid/language_models/prompt_formatter/hf_formatter.py +135 -0
  61. langroid/language_models/utils.py +18 -21
  62. langroid/mytypes.py +25 -8
  63. langroid/parsing/__init__.py +46 -0
  64. langroid/parsing/document_parser.py +260 -63
  65. langroid/parsing/image_text.py +32 -0
  66. langroid/parsing/parse_json.py +143 -0
  67. langroid/parsing/parser.py +122 -59
  68. langroid/parsing/repo_loader.py +114 -52
  69. langroid/parsing/search.py +68 -63
  70. langroid/parsing/spider.py +3 -2
  71. langroid/parsing/table_loader.py +44 -0
  72. langroid/parsing/url_loader.py +59 -11
  73. langroid/parsing/urls.py +85 -37
  74. langroid/parsing/utils.py +298 -4
  75. langroid/parsing/web_search.py +73 -0
  76. langroid/prompts/__init__.py +11 -0
  77. langroid/prompts/chat-gpt4-system-prompt.md +68 -0
  78. langroid/prompts/prompts_config.py +1 -1
  79. langroid/utils/__init__.py +17 -0
  80. langroid/utils/algorithms/__init__.py +3 -0
  81. langroid/utils/algorithms/graph.py +103 -0
  82. langroid/utils/configuration.py +36 -5
  83. langroid/utils/constants.py +4 -0
  84. langroid/utils/globals.py +2 -2
  85. langroid/utils/logging.py +2 -5
  86. langroid/utils/output/__init__.py +21 -0
  87. langroid/utils/output/printing.py +47 -1
  88. langroid/utils/output/status.py +33 -0
  89. langroid/utils/pandas_utils.py +30 -0
  90. langroid/utils/pydantic_utils.py +616 -2
  91. langroid/utils/system.py +98 -0
  92. langroid/vector_store/__init__.py +40 -0
  93. langroid/vector_store/base.py +203 -6
  94. langroid/vector_store/chromadb.py +59 -32
  95. langroid/vector_store/lancedb.py +463 -0
  96. langroid/vector_store/meilisearch.py +10 -7
  97. langroid/vector_store/momento.py +262 -0
  98. langroid/vector_store/qdrantdb.py +104 -22
  99. {langroid-0.1.85.dist-info → langroid-0.1.219.dist-info}/METADATA +329 -149
  100. langroid-0.1.219.dist-info/RECORD +127 -0
  101. {langroid-0.1.85.dist-info → langroid-0.1.219.dist-info}/WHEEL +1 -1
  102. langroid/agent/special/recipient_validator_agent.py +0 -157
  103. langroid/parsing/json.py +0 -64
  104. langroid/utils/web/selenium_login.py +0 -36
  105. langroid-0.1.85.dist-info/RECORD +0 -94
  106. /langroid/{scripts → agent/callbacks}/__init__.py +0 -0
  107. {langroid-0.1.85.dist-info → langroid-0.1.219.dist-info}/LICENSE +0 -0
langroid/agent/task.py CHANGED
@@ -1,9 +1,25 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import copy
3
4
  import logging
4
- from typing import Any, Callable, Coroutine, Dict, List, Optional, Set, Type, cast
5
+ import re
6
+ from collections import Counter
7
+ from types import SimpleNamespace
8
+ from typing import (
9
+ Any,
10
+ Callable,
11
+ Coroutine,
12
+ Dict,
13
+ List,
14
+ Optional,
15
+ Set,
16
+ Tuple,
17
+ Type,
18
+ cast,
19
+ )
5
20
 
6
21
  from rich import print
22
+ from rich.markup import escape
7
23
 
8
24
  from langroid.agent.base import Agent
9
25
  from langroid.agent.chat_agent import ChatAgent
@@ -13,8 +29,9 @@ from langroid.agent.chat_document import (
13
29
  ChatDocument,
14
30
  )
15
31
  from langroid.mytypes import Entity
32
+ from langroid.parsing.parse_json import extract_top_level_json
16
33
  from langroid.utils.configuration import settings
17
- from langroid.utils.constants import DONE, NO_ANSWER, USER_QUIT
34
+ from langroid.utils.constants import DONE, NO_ANSWER, PASS, PASS_TO, SEND_TO, USER_QUIT
18
35
  from langroid.utils.logging import RichFileLogger, setup_file_logger
19
36
 
20
37
  logger = logging.getLogger(__name__)
@@ -22,6 +39,10 @@ logger = logging.getLogger(__name__)
22
39
  Responder = Entity | Type["Task"]
23
40
 
24
41
 
42
+ def noop_fn(*args: List[Any], **kwargs: Dict[str, Any]) -> None:
43
+ pass
44
+
45
+
25
46
  class Task:
26
47
  """
27
48
  A `Task` wraps an `Agent` object, and sets up the `Agent`'s goals and instructions.
@@ -54,17 +75,21 @@ class Task:
54
75
 
55
76
  def __init__(
56
77
  self,
57
- agent: Agent,
78
+ agent: Optional[Agent] = None,
58
79
  name: str = "",
59
80
  llm_delegate: bool = False,
60
81
  single_round: bool = False,
61
82
  system_message: str = "",
62
- user_message: str = "",
63
- restart: bool = False,
83
+ user_message: str | None = "",
84
+ restart: bool = True,
64
85
  default_human_response: Optional[str] = None,
65
86
  interactive: bool = True,
66
- only_user_quits_root: bool = True,
87
+ only_user_quits_root: bool = False,
67
88
  erase_substeps: bool = False,
89
+ allow_null_result: bool = True,
90
+ max_stalled_steps: int = 5,
91
+ done_if_no_response: List[Responder] = [],
92
+ done_if_response: List[Responder] = [],
68
93
  ):
69
94
  """
70
95
  A task to be performed by an agent.
@@ -72,12 +97,17 @@ class Task:
72
97
  Args:
73
98
  agent (Agent): agent associated with the task
74
99
  name (str): name of the task
75
- llm_delegate (bool): whether to delegate control to LLM; conceptually,
100
+ llm_delegate (bool):
101
+ [Deprecated, not used; use `done_if_response`, `done_if_no_response`
102
+ instead]
103
+ Whether to delegate control to LLM; conceptually,
76
104
  the "controlling entity" is the one "seeking" responses to its queries,
77
105
  and has a goal it is aiming to achieve. The "controlling entity" is
78
106
  either the LLM or the USER. (Note within a Task there is just one
79
107
  LLM, and all other entities are proxies of the "User" entity).
80
- single_round (bool): If true, task runs until one message by controller,
108
+ single_round (bool):
109
+ [Deprecated: Use `done_if_response`, `done_if_no_response` instead].
110
+ If true, task runs until one message by controller,
81
111
  and subsequent response by non-controller. If false, runs for the
82
112
  specified number of turns in `run`, or until `done()` is true.
83
113
  One run of step() is considered a "turn".
@@ -86,19 +116,54 @@ class Task:
86
116
  restart (bool): if true, resets the agent's message history
87
117
  default_human_response (str): default response from user; useful for
88
118
  testing, to avoid interactive input from user.
119
+ [Instead of this, setting `interactive` usually suffices]
89
120
  interactive (bool): if true, wait for human input after each non-human
90
121
  response (prevents infinite loop of non-human responses).
91
122
  Default is true. If false, then `default_human_response` is set to ""
92
123
  only_user_quits_root (bool): if true, only user can quit the root task.
124
+ [This param is ignored & deprecated; Keeping for backward compatibility.
125
+ Instead of this, setting `interactive` suffices]
93
126
  erase_substeps (bool): if true, when task completes, erase intermediate
94
127
  conversation with subtasks from this agent's `message_history`, and also
95
128
  erase all subtask agents' `message_history`.
96
129
  Note: erasing can reduce prompt sizes, but results in repetitive
97
130
  sub-task delegation.
131
+ allow_null_result (bool): [Deprecated, may be removed in future.]
132
+ If true, allow null (empty or NO_ANSWER)
133
+ as the result of a step or overall task result.
134
+ Optional, default is True.
135
+ max_stalled_steps (int): task considered done after this many consecutive
136
+ steps with no progress. Default is 3.
137
+ done_if_no_response (List[Responder]): consider task done if NULL
138
+ response from any of these responders. Default is empty list.
139
+ done_if_response (List[Responder]): consider task done if NON-NULL
140
+ response from any of these responders. Default is empty list.
98
141
  """
142
+ if agent is None:
143
+ agent = ChatAgent()
144
+
145
+ self.callbacks = SimpleNamespace(
146
+ show_subtask_response=noop_fn,
147
+ set_parent_agent=noop_fn,
148
+ )
149
+ # copy the agent's config, so that we don't modify the original agent's config,
150
+ # which may be shared by other agents.
151
+ try:
152
+ config_copy = copy.deepcopy(agent.config)
153
+ agent.config = config_copy
154
+ except Exception:
155
+ logger.warning(
156
+ """
157
+ Failed to deep-copy Agent config during task creation,
158
+ proceeding with original config. Be aware that changes to
159
+ the config may affect other agents using the same config.
160
+ """
161
+ )
162
+
99
163
  if isinstance(agent, ChatAgent) and len(agent.message_history) == 0 or restart:
100
164
  agent = cast(ChatAgent, agent)
101
165
  agent.clear_history(0)
166
+ agent.clear_dialog()
102
167
  # possibly change the system and user messages
103
168
  if system_message:
104
169
  # we always have at least 1 task_message
@@ -108,18 +173,41 @@ class Task:
108
173
 
109
174
  self.logger: None | RichFileLogger = None
110
175
  self.tsv_logger: None | logging.Logger = None
111
- self.color_log: bool = True
176
+ self.color_log: bool = False if settings.notebook else True
112
177
  self.agent = agent
178
+ self.step_progress = False # progress in current step?
179
+ self.n_stalled_steps = 0 # how many consecutive steps with no progress?
180
+ self.max_stalled_steps = max_stalled_steps
181
+ self.done_if_response = [r.value for r in done_if_response]
182
+ self.done_if_no_response = [r.value for r in done_if_no_response]
183
+ self.is_done = False # is task done (based on response)?
184
+ self.is_pass_thru = False # is current response a pass-thru?
185
+ self.task_progress = False # progress in current task (since run or run_async)?
186
+ if name:
187
+ # task name overrides name in agent config
188
+ agent.config.name = name
113
189
  self.name = name or agent.config.name
190
+ self.value: str = self.name
114
191
  self.default_human_response = default_human_response
192
+ if default_human_response is not None and default_human_response == "":
193
+ interactive = False
115
194
  self.interactive = interactive
116
195
  self.message_history_idx = -1
117
- if not interactive:
118
- self.default_human_response = ""
196
+ if interactive:
197
+ only_user_quits_root = True
198
+ else:
199
+ default_human_response = default_human_response or ""
200
+ only_user_quits_root = False
119
201
  if default_human_response is not None:
120
202
  self.agent.default_human_response = default_human_response
203
+ if self.interactive:
204
+ self.agent.default_human_response = None
121
205
  self.only_user_quits_root = only_user_quits_root
206
+ # set to True if we want to collapse multi-turn conversation with sub-tasks into
207
+ # just the first outgoing message and last incoming message.
208
+ # Note this also completely erases sub-task agents' message_history.
122
209
  self.erase_substeps = erase_substeps
210
+ self.allow_null_result = allow_null_result
123
211
 
124
212
  agent_entity_responders = agent.entity_responders()
125
213
  agent_entity_responders_async = agent.entity_responders_async()
@@ -149,6 +237,7 @@ class Task:
149
237
  self.pending_sender: Responder = Entity.USER
150
238
  self.single_round = single_round
151
239
  self.turns = -1 # no limit
240
+ self.llm_delegate = llm_delegate
152
241
  if llm_delegate:
153
242
  self.controller = Entity.LLM
154
243
  if self.single_round:
@@ -166,6 +255,29 @@ class Task:
166
255
  self.parent_task: Set[Task] = set()
167
256
  self.caller: Task | None = None # which task called this task's `run` method
168
257
 
258
+ def clone(self, i: int) -> "Task":
259
+ """
260
+ Returns a copy of this task, with a new agent.
261
+ """
262
+ assert isinstance(self.agent, ChatAgent), "Task clone only works for ChatAgent"
263
+ agent: ChatAgent = self.agent.clone(i)
264
+ return Task(
265
+ agent,
266
+ name=self.name + f"-{i}",
267
+ llm_delegate=self.llm_delegate,
268
+ single_round=self.single_round,
269
+ system_message=self.agent.system_message,
270
+ user_message=self.agent.user_message,
271
+ restart=False,
272
+ default_human_response=self.default_human_response,
273
+ interactive=self.interactive,
274
+ erase_substeps=self.erase_substeps,
275
+ allow_null_result=self.allow_null_result,
276
+ max_stalled_steps=self.max_stalled_steps,
277
+ done_if_no_response=[Entity(s) for s in self.done_if_no_response],
278
+ done_if_response=[Entity(s) for s in self.done_if_response],
279
+ )
280
+
169
281
  def __repr__(self) -> str:
170
282
  return f"{self.name}"
171
283
 
@@ -266,6 +378,11 @@ class Task:
266
378
  ) -> Optional[ChatDocument]:
267
379
  """Synchronous version of `run_async()`.
268
380
  See `run_async()` for details."""
381
+ self.task_progress = False
382
+ self.n_stalled_steps = 0
383
+ assert (
384
+ msg is None or isinstance(msg, str) or isinstance(msg, ChatDocument)
385
+ ), f"msg arg in Task.run() must be None, str, or ChatDocument, not {type(msg)}"
269
386
 
270
387
  if (
271
388
  isinstance(msg, ChatDocument)
@@ -285,7 +402,7 @@ class Task:
285
402
  while True:
286
403
  self.step()
287
404
  if self.done():
288
- if self._level == 0:
405
+ if self._level == 0 and not settings.quiet:
289
406
  print("[magenta]Bye, hope this was useful!")
290
407
  break
291
408
  i += 1
@@ -326,7 +443,7 @@ class Task:
326
443
  # have come from another LLM), as far as this agent is concerned, the initial
327
444
  # message can be considered to be from the USER
328
445
  # (from the POV of this agent's LLM).
329
-
446
+ self.task_progress = False
330
447
  if (
331
448
  isinstance(msg, ChatDocument)
332
449
  and msg.metadata.recipient != ""
@@ -345,7 +462,7 @@ class Task:
345
462
  while True:
346
463
  await self.step_async()
347
464
  if self.done():
348
- if self._level == 0:
465
+ if self._level == 0 and not settings.quiet:
349
466
  print("[magenta]Bye, hope this was useful!")
350
467
  break
351
468
  i += 1
@@ -386,10 +503,12 @@ class Task:
386
503
  if self.agent.config.llm is None
387
504
  else self.agent.config.llm.chat_model
388
505
  )
389
- print(
390
- f"[bold magenta]{self._enter} Starting Agent "
391
- f"{self.name} ({self.message_history_idx+1}) {llm_model} [/bold magenta]"
392
- )
506
+ if not settings.quiet:
507
+ print(
508
+ f"[bold magenta]{self._enter} Starting Agent "
509
+ f"{self.name} ({self.message_history_idx+1}) "
510
+ f"{llm_model} [/bold magenta]"
511
+ )
393
512
 
394
513
  def _post_run_loop(self) -> None:
395
514
  # delete all messages from our agent's history, AFTER the first incoming
@@ -412,16 +531,20 @@ class Task:
412
531
  # ONLY talking to the current agent.
413
532
  if isinstance(t.agent, ChatAgent):
414
533
  t.agent.clear_history(0)
415
- print(
416
- f"[bold magenta]{self._leave} Finished Agent "
417
- f"{self.name} ({n_messages}) [/bold magenta]"
418
- )
534
+ if not settings.quiet:
535
+ print(
536
+ f"[bold magenta]{self._leave} Finished Agent "
537
+ f"{self.name} ({n_messages}) [/bold magenta]"
538
+ )
419
539
 
420
540
  def step(self, turns: int = -1) -> ChatDocument | None:
421
541
  """
422
542
  Synchronous version of `step_async()`. See `step_async()` for details.
543
+ TODO: Except for the self.response() calls, this fn should be identical to
544
+ `step_async()`. Consider refactoring to avoid duplication.
423
545
  """
424
- result = None
546
+ self.is_done = False
547
+ self.step_progress = False
425
548
  parent = self.pending_message
426
549
  recipient = (
427
550
  ""
@@ -437,16 +560,30 @@ class Task:
437
560
  sender_name=Entity.AGENT,
438
561
  ),
439
562
  )
440
- self.pending_message = error_doc
563
+ self._process_valid_responder_result(Entity.AGENT, parent, error_doc)
441
564
  return error_doc
442
565
 
443
566
  responders: List[Responder] = self.non_human_responders.copy()
444
- if Entity.USER in self.responders and not self.human_tried:
445
- # give human first chance if they haven't been tried in last step:
446
- # ensures human gets chance at each turn.
567
+
568
+ if (
569
+ Entity.USER in self.responders
570
+ and not self.human_tried
571
+ and not self.agent.has_tool_message_attempt(self.pending_message)
572
+ ):
573
+ # Give human first chance if they haven't been tried in last step,
574
+ # and the msg is not a tool-call attempt;
575
+ # This ensures human gets a chance to respond,
576
+ # other than to a LLM tool-call.
577
+ # When there's a tool msg attempt we want the
578
+ # Agent to be the next responder; this only makes a difference in an
579
+ # interactive setting: LLM generates tool, then we don't want user to
580
+ # have to respond, and instead let the agent_response handle the tool.
581
+
447
582
  responders.insert(0, Entity.USER)
448
583
 
584
+ found_response = False
449
585
  for r in responders:
586
+ self.is_pass_thru = False
450
587
  if not self._can_respond(r):
451
588
  # create dummy msg for logging
452
589
  log_doc = ChatDocument(
@@ -462,10 +599,19 @@ class Task:
462
599
  continue
463
600
  self.human_tried = r == Entity.USER
464
601
  result = self.response(r, turns)
465
- is_break = self._process_responder_result(r, parent, result)
466
- if is_break:
602
+ self.is_done = self._is_done_response(result, r)
603
+ self.is_pass_thru = PASS in result.content if result else False
604
+ if self.valid(result, r):
605
+ found_response = True
606
+ assert result is not None
607
+ self._process_valid_responder_result(r, parent, result)
608
+ break
609
+ else:
610
+ self.log_message(r, result)
611
+ if self.is_done:
612
+ # skip trying other responders in this step
467
613
  break
468
- if not self.valid(result):
614
+ if not found_response:
469
615
  self._process_invalid_step_result(parent)
470
616
  self._show_pending_message_if_debug()
471
617
  return self.pending_message
@@ -491,7 +637,8 @@ class Task:
491
637
  other use-cases, e.g. where we want to run a task step by step in a
492
638
  different context.
493
639
  """
494
- result = None
640
+ self.is_done = False
641
+ self.step_progress = False
495
642
  parent = self.pending_message
496
643
  recipient = (
497
644
  ""
@@ -507,15 +654,27 @@ class Task:
507
654
  sender_name=Entity.AGENT,
508
655
  ),
509
656
  )
510
- self.pending_message = error_doc
657
+ self._process_valid_responder_result(Entity.AGENT, parent, error_doc)
511
658
  return error_doc
512
659
 
513
660
  responders: List[Responder] = self.non_human_responders_async.copy()
514
- if Entity.USER in self.responders_async and not self.human_tried:
515
- # give human first chance if they haven't been tried in last step:
516
- # ensures human gets chance at each turn.
661
+
662
+ if (
663
+ Entity.USER in self.responders
664
+ and not self.human_tried
665
+ and not self.agent.has_tool_message_attempt(self.pending_message)
666
+ ):
667
+ # Give human first chance if they haven't been tried in last step,
668
+ # and the msg is not a tool-call attempt;
669
+ # This ensures human gets a chance to respond,
670
+ # other than to a LLM tool-call.
671
+ # When there's a tool msg attempt we want the
672
+ # Agent to be the next responder; this only makes a difference in an
673
+ # interactive setting: LLM generates tool, then we don't want user to
674
+ # have to respond, and instead let the agent_response handle the tool.
517
675
  responders.insert(0, Entity.USER)
518
676
 
677
+ found_response = False
519
678
  for r in responders:
520
679
  if not self._can_respond(r):
521
680
  # create dummy msg for logging
@@ -532,67 +691,117 @@ class Task:
532
691
  continue
533
692
  self.human_tried = r == Entity.USER
534
693
  result = await self.response_async(r, turns)
535
- is_break = self._process_responder_result(r, parent, result)
536
- if is_break:
694
+ self.is_done = self._is_done_response(result, r)
695
+ self.is_pass_thru = PASS in result.content if result else False
696
+ if self.valid(result, r):
697
+ found_response = True
698
+ assert result is not None
699
+ self._process_valid_responder_result(r, parent, result)
700
+ break
701
+ else:
702
+ self.log_message(r, result)
703
+ if self.is_done:
704
+ # skip trying other responders in this step
537
705
  break
538
- if not self.valid(result):
706
+ if not found_response:
539
707
  self._process_invalid_step_result(parent)
540
708
  self._show_pending_message_if_debug()
541
709
  return self.pending_message
542
710
 
543
- def _process_responder_result(
711
+ def _process_valid_responder_result(
544
712
  self,
545
713
  r: Responder,
546
714
  parent: ChatDocument | None,
547
- result: ChatDocument | None,
548
- ) -> bool:
549
- """Processes result and returns whether to break out
550
- of the loop in `step` or `step_async`."""
551
-
552
- if self.valid(result):
553
- assert result is not None
715
+ result: ChatDocument,
716
+ ) -> None:
717
+ """Processes valid result from a responder, during a step"""
718
+
719
+ # pending_sender is of type Responder,
720
+ # i.e. it is either one of the agent's entities
721
+ # OR a sub-task, that has produced a valid response.
722
+ # Contrast this with self.pending_message.metadata.sender, which is an ENTITY
723
+ # of this agent, or a sub-task's agent.
724
+ if not self.is_pass_thru:
554
725
  self.pending_sender = r
555
- if result.metadata.parent_responder is not None and not isinstance(
556
- r, Entity
557
- ):
558
- # When result is from a sub-task, and `result.metadata` contains
559
- # a non-null `parent_responder`, pretend this result was
560
- # from the parent_responder, by setting `self.pending_sender`.
561
- self.pending_sender = result.metadata.parent_responder
562
- # Since we've just used the "pretend responder",
563
- # clear out the pretend responder in metadata
564
- # (so that it doesn't get used again)
565
- result.metadata.parent_responder = None
566
- result.metadata.parent = parent
567
- old_attachment = (
568
- self.pending_message.attachment if self.pending_message else None
569
- )
726
+ result.metadata.parent = parent
727
+ if not self.is_pass_thru:
570
728
  self.pending_message = result
571
- # if result has no attachment, preserve the old attachment
572
- if result.attachment is None:
573
- self.pending_message.attachment = old_attachment
574
- self.log_message(self.pending_sender, result, mark=True)
575
- return True
729
+ self.log_message(self.pending_sender, result, mark=True)
730
+ self.step_progress = True
731
+ self.task_progress = True
732
+ if self.is_pass_thru:
733
+ self.n_stalled_steps += 1
576
734
  else:
577
- self.log_message(r, result)
578
- return False
735
+ # reset stuck counter since we made progress
736
+ self.n_stalled_steps = 0
579
737
 
580
738
  def _process_invalid_step_result(self, parent: ChatDocument | None) -> None:
581
- responder = Entity.LLM if self.pending_sender == Entity.USER else Entity.USER
582
- self.pending_message = ChatDocument(
583
- content=NO_ANSWER,
584
- metadata=ChatDocMetaData(sender=responder, parent=parent),
585
- )
586
- self.pending_sender = responder
739
+ """
740
+ Since step had no valid result from any responder, decide whether to update the
741
+ self.pending_message to a NO_ANSWER message from the opposite entity,
742
+ or leave it as is.
743
+ Args:
744
+ parent (ChatDocument|None): parent message of the current message
745
+ """
746
+ self.n_stalled_steps += 1
747
+ if (not self.task_progress or self.allow_null_result) and not self.is_pass_thru:
748
+ # There has been no progress at all in this task, so we
749
+ # update the pending_message to a dummy NO_ANSWER msg
750
+ # from the entity 'opposite' to the current pending_sender,
751
+ # so we show "progress" and avoid getting stuck in an infinite loop.
752
+ responder = (
753
+ Entity.LLM if self.pending_sender == Entity.USER else Entity.USER
754
+ )
755
+ self.pending_message = ChatDocument(
756
+ content=NO_ANSWER,
757
+ metadata=ChatDocMetaData(sender=responder, parent=parent),
758
+ )
759
+ self.pending_sender = responder
587
760
  self.log_message(self.pending_sender, self.pending_message, mark=True)
588
761
 
589
762
  def _show_pending_message_if_debug(self) -> None:
590
763
  if self.pending_message is None:
591
764
  return
592
765
  if settings.debug:
593
- sender_str = str(self.pending_sender)
594
- msg_str = str(self.pending_message)
595
- print(f"[red][{sender_str}]{msg_str}")
766
+ sender_str = escape(str(self.pending_sender))
767
+ msg_str = escape(str(self.pending_message))
768
+ print(f"[grey37][{sender_str}]{msg_str}[/grey37]")
769
+
770
+ def _parse_routing(self, msg: ChatDocument | str) -> Tuple[bool | None, str | None]:
771
+ """
772
+ Parse routing instruction if any, of the form:
773
+ PASS:<recipient> (pass current pending msg to recipient)
774
+ SEND:<recipient> <content> (send content to recipient)
775
+ Args:
776
+ msg (ChatDocument|str|None): message to parse
777
+ Returns:
778
+ Tuple[bool,str|None]:
779
+ bool: true=PASS, false=SEND, or None if neither
780
+ str: recipient, or None
781
+ """
782
+ # handle routing instruction in result if any,
783
+ # of the form PASS=<recipient>
784
+ content = msg.content if isinstance(msg, ChatDocument) else msg
785
+ content = content.strip()
786
+ if PASS in content and PASS_TO not in content:
787
+ return True, None
788
+ if PASS_TO in content and content.split(":")[1] != "":
789
+ return True, content.split(":")[1]
790
+ if SEND_TO in content and (send_parts := re.split(r"[,: ]", content))[1] != "":
791
+ # assume syntax is SEND_TO:<recipient> <content>
792
+ # or SEND_TO:<recipient>,<content> or SEND_TO:<recipient>:<content>
793
+ recipient = send_parts[1].strip()
794
+ # get content to send, clean out routing instruction, and
795
+ # start from 1 char after SEND_TO:<recipient>,
796
+ # because we expect there is either a blank or some other separator
797
+ # after the recipient
798
+ content_to_send = content.replace(f"{SEND_TO}{recipient}", "").strip()[1:]
799
+ # if no content then treat same as PASS_TO
800
+ if content_to_send == "":
801
+ return True, recipient
802
+ else:
803
+ return False, recipient
804
+ return None, None
596
805
 
597
806
  def response(
598
807
  self,
@@ -604,17 +813,57 @@ class Task:
604
813
  """
605
814
  if isinstance(e, Task):
606
815
  actual_turns = e.turns if e.turns > 0 else turns
816
+ e.agent.callbacks.set_parent_agent(self.agent)
817
+ # e.callbacks.set_parent_agent(self.agent)
607
818
  result = e.run(
608
819
  self.pending_message,
609
820
  turns=actual_turns,
610
821
  caller=self,
611
822
  )
612
- return result
823
+ result_str = str(ChatDocument.to_LLMMessage(result))
824
+ maybe_tool = len(extract_top_level_json(result_str)) > 0
825
+ self.callbacks.show_subtask_response(
826
+ task=e,
827
+ content=result_str,
828
+ is_tool=maybe_tool,
829
+ )
613
830
  else:
614
- # Note we always use async responders, even though
615
- # ultimately a synch endpoint is used.
616
831
  response_fn = self._entity_responder_map[cast(Entity, e)]
617
832
  result = response_fn(self.pending_message)
833
+ return self._process_result_routing(result)
834
+
835
+ def _process_result_routing(
836
+ self, result: ChatDocument | None
837
+ ) -> ChatDocument | None:
838
+ # process result in case there is a routing instruction
839
+ if result is None:
840
+ return None
841
+ is_pass, recipient = self._parse_routing(result)
842
+ if is_pass is None: # no routing, i.e. neither PASS nor SEND
843
+ return result
844
+ if is_pass:
845
+ if recipient is None or self.pending_message is None:
846
+ # Just PASS, no recipient
847
+ # This means pass on self.pending_message to the next responder
848
+ # in the default sequence of responders.
849
+ # So leave result intact since we handle "PASS" in step()
850
+ return result
851
+ # set recipient in self.pending_message
852
+ self.pending_message.metadata.recipient = recipient
853
+ # clear out recipient, replace with just PASS
854
+ result.content = result.content.replace(
855
+ f"{PASS_TO}:{recipient}", PASS
856
+ ).strip()
857
+ return result
858
+ elif recipient is not None:
859
+ # we are sending non-empty content to non-null recipient
860
+ # clean up result.content, set metadata.recipient and return
861
+ result.content = result.content.replace(
862
+ f"{SEND_TO}:{recipient}", ""
863
+ ).strip()
864
+ result.metadata.recipient = recipient
865
+ return result
866
+ else:
618
867
  return result
619
868
 
620
869
  async def response_async(
@@ -639,16 +888,24 @@ class Task:
639
888
  """
640
889
  if isinstance(e, Task):
641
890
  actual_turns = e.turns if e.turns > 0 else turns
891
+ e.agent.callbacks.set_parent_agent(self.agent)
892
+ # e.callbacks.set_parent_agent(self.agent)
642
893
  result = await e.run_async(
643
894
  self.pending_message,
644
895
  turns=actual_turns,
645
896
  caller=self,
646
897
  )
647
- return result
898
+ result_str = str(ChatDocument.to_LLMMessage(result))
899
+ maybe_tool = len(extract_top_level_json(result_str)) > 0
900
+ self.callbacks.show_subtask_response(
901
+ task=e,
902
+ content=result_str,
903
+ is_tool=maybe_tool,
904
+ )
648
905
  else:
649
906
  response_fn = self._entity_responder_async_map[cast(Entity, e)]
650
907
  result = await response_fn(self.pending_message)
651
- return result
908
+ return self._process_result_routing(result)
652
909
 
653
910
  def result(self) -> ChatDocument:
654
911
  """
@@ -664,10 +921,11 @@ class Task:
664
921
  # assuming it is of the form "DONE: <content>"
665
922
  content = content.replace(DONE, "").strip()
666
923
  fun_call = result_msg.function_call if result_msg else None
667
- attachment = result_msg.attachment if result_msg else None
924
+ tool_messages = result_msg.tool_messages if result_msg else []
668
925
  block = result_msg.metadata.block if result_msg else None
669
926
  recipient = result_msg.metadata.recipient if result_msg else None
670
927
  responder = result_msg.metadata.parent_responder if result_msg else None
928
+ tool_ids = result_msg.metadata.tool_ids if result_msg else []
671
929
 
672
930
  # regardless of which entity actually produced the result,
673
931
  # when we return the result, we set entity to USER
@@ -675,7 +933,7 @@ class Task:
675
933
  return ChatDocument(
676
934
  content=content,
677
935
  function_call=fun_call,
678
- attachment=attachment,
936
+ tool_messages=tool_messages,
679
937
  metadata=ChatDocMetaData(
680
938
  source=Entity.USER,
681
939
  sender=Entity.USER,
@@ -683,60 +941,156 @@ class Task:
683
941
  parent_responder=responder,
684
942
  sender_name=self.name,
685
943
  recipient=recipient,
944
+ tool_ids=tool_ids,
686
945
  ),
687
946
  )
688
947
 
689
- def done(self) -> bool:
948
+ def _is_empty_message(self, msg: str | ChatDocument | None) -> bool:
949
+ """
950
+ Check if msg is empty or None
951
+ Args:
952
+ msg (str|ChatDocument|None): message to check
953
+ Returns:
954
+ bool: True if msg is (equivalent to) empty or None, False otherwise
955
+ """
956
+ return (
957
+ msg is None
958
+ or (isinstance(msg, str) and msg.strip() in [PASS, ""])
959
+ or (
960
+ isinstance(msg, ChatDocument)
961
+ and msg.content.strip() in [PASS, ""]
962
+ and msg.function_call is None
963
+ and msg.tool_messages == []
964
+ )
965
+ )
966
+
967
+ def _is_done_response(
968
+ self, result: str | None | ChatDocument, responder: Responder
969
+ ) -> bool:
970
+ """Is the task done based on the response from the given responder?"""
971
+
972
+ response_says_done = result is not None and (
973
+ (isinstance(result, str) and DONE in result)
974
+ or (isinstance(result, ChatDocument) and DONE in result.content)
975
+ )
976
+ return (
977
+ (
978
+ responder.value in self.done_if_response
979
+ and not self._is_empty_message(result)
980
+ )
981
+ or (
982
+ responder.value in self.done_if_no_response
983
+ and self._is_empty_message(result)
984
+ )
985
+ or (not self._is_empty_message(result) and response_says_done)
986
+ )
987
+
988
+ def _maybe_infinite_loop(self, history: int = 10) -> bool:
989
+ """
990
+ TODO Not currently used, until we figure out best way.
991
+ Check if {NO_ANSWER}, empty answer, or a specific non-LLM msg occurs too
992
+ often in history of pending messages -- this can be an indicator of a possible
993
+ multi-step infinite loop that we should exit.
994
+ (A single-step infinite loop is where individual steps don't show progress
995
+ and are easy to detect via n_stalled_steps, but a multi-step infinite loop
996
+ could show "progress" at each step, but can still be an infinite loop, e.g.
997
+ if the steps are just alternating between two messages).
998
+ """
999
+ p = self.pending_message
1000
+ n_no_answers = 0
1001
+ n_empty_answers = 0
1002
+ counter: Counter[str] = Counter()
1003
+ # count number of NO_ANSWER and empty answers in last up to 10 messages
1004
+ # in ancestors of self.pending_message
1005
+ for _ in range(history):
1006
+ if p is None:
1007
+ break
1008
+ n_no_answers += p.content.strip() == NO_ANSWER
1009
+ n_empty_answers += p.content.strip() == "" and p.function_call is None
1010
+ if p.metadata.sender != Entity.LLM and PASS not in p.content:
1011
+ counter.update([p.metadata.sender + ":" + p.content])
1012
+ p = p.metadata.parent
1013
+
1014
+ # freq of most common message in history
1015
+ high_freq = (counter.most_common(1) or [("", 0)])[0][1]
1016
+ # We deem this a potential infinite loop if:
1017
+ # - a specific non-LLM msg occurs too often, or
1018
+ # - a NO_ANSWER or empty answer occurs too often
1019
+ return max(high_freq, n_no_answers) > self.max_stalled_steps
1020
+
1021
+ def done(
1022
+ self, result: ChatDocument | None = None, r: Responder | None = None
1023
+ ) -> bool:
690
1024
  """
691
1025
  Check if task is done. This is the default behavior.
692
1026
  Derived classes can override this.
1027
+ Args:
1028
+ result (ChatDocument|None): result from a responder
1029
+ r (Responder|None): responder that produced the result
1030
+ Not used here, but could be used by derived classes.
693
1031
  Returns:
694
1032
  bool: True if task is done, False otherwise
695
1033
  """
1034
+ result = result or self.pending_message
696
1035
  user_quit = (
697
- self.pending_message is not None
698
- and self.pending_message.content in USER_QUIT
699
- and self.pending_message.metadata.sender == Entity.USER
1036
+ result is not None
1037
+ and result.content in USER_QUIT
1038
+ and result.metadata.sender == Entity.USER
700
1039
  )
701
1040
  if self._level == 0 and self.only_user_quits_root:
702
1041
  # for top-level task, only user can quit out
703
1042
  return user_quit
704
1043
 
1044
+ if self.is_done:
1045
+ return True
1046
+
1047
+ if self.n_stalled_steps >= self.max_stalled_steps:
1048
+ # we are stuck, so bail to avoid infinite loop
1049
+ logger.warning(
1050
+ f"Task {self.name} stuck for {self.max_stalled_steps} steps; exiting."
1051
+ )
1052
+ return True
1053
+
705
1054
  return (
706
1055
  # no valid response from any entity/agent in current turn
707
- self.pending_message is None
708
- # LLM decided task is done
709
- or DONE in self.pending_message.content
1056
+ result is None
1057
+ # An entity decided task is done
1058
+ or DONE in result.content
710
1059
  or ( # current task is addressing message to caller task
711
1060
  self.caller is not None
712
1061
  and self.caller.name != ""
713
- and self.pending_message.metadata.recipient == self.caller.name
714
- )
715
- or (
716
- # Task controller is "stuck", has nothing to say
717
- NO_ANSWER in self.pending_message.content
718
- and self.pending_message.metadata.sender == self.controller
1062
+ and result.metadata.recipient == self.caller.name
719
1063
  )
1064
+ # or (
1065
+ # # Task controller is "stuck", has nothing to say
1066
+ # NO_ANSWER in result.content
1067
+ # and result.metadata.sender == self.controller
1068
+ # )
720
1069
  or user_quit
721
1070
  )
722
1071
 
723
- def valid(self, result: Optional[ChatDocument]) -> bool:
1072
+ def valid(
1073
+ self,
1074
+ result: Optional[ChatDocument],
1075
+ r: Responder,
1076
+ ) -> bool:
724
1077
  """
725
- Is the result from an entity or sub-task such that we can stop searching
726
- for responses for this turn?
1078
+ Is the result from a Responder (i.e. an entity or sub-task)
1079
+ such that we can stop searching for responses in this step?
727
1080
  """
728
1081
  # TODO caution we should ensure that no handler method (tool) returns simply
729
1082
  # an empty string (e.g when showing contents of an empty file), since that
730
1083
  # would be considered an invalid response, and other responders will wrongly
731
1084
  # be given a chance to respond.
1085
+
1086
+ # if task would be considered done given responder r's `result`,
1087
+ # then consider the result valid.
1088
+ if result is not None and self.done(result, r):
1089
+ return True
732
1090
  return (
733
1091
  result is not None
734
- and (result.content != "" or result.function_call is not None)
735
- and ( # if NO_ANSWER is from controller, then it means
736
- # controller is stuck and we are done with task loop
737
- NO_ANSWER not in result.content
738
- or result.metadata.sender == self.controller
739
- )
1092
+ and not self._is_empty_message(result)
1093
+ and result.content.strip() != NO_ANSWER
740
1094
  )
741
1095
 
742
1096
  def log_message(
@@ -802,35 +1156,34 @@ class Task:
802
1156
  """
803
1157
  if recipient == "":
804
1158
  return True
805
- responder_names = [self.name] + [r.name for r in self.responders]
806
- return recipient in responder_names
1159
+ # native responders names are USER, LLM, AGENT,
1160
+ # and the names of subtasks are from Task.name attribute
1161
+ responder_names = [self.name.lower()] + [
1162
+ r.name.lower() for r in self.responders
1163
+ ]
1164
+ return recipient.lower() in responder_names
807
1165
 
808
1166
  def _recipient_mismatch(self, e: Responder) -> bool:
809
1167
  """
810
1168
  Is the recipient explicitly specified and does not match responder "e" ?
811
1169
  """
812
- if self.pending_message is None:
813
- return False
814
- recipient = self.pending_message.metadata.recipient
815
- if recipient == "":
816
- return False
817
- # LLM-specified recipient could be an entity such as USER or AGENT,
818
- # or the name of another task.
819
- return recipient not in (e.name, self.name)
1170
+ # Note that recipient could be specified as an Entity or a Task name
1171
+ return (
1172
+ self.pending_message is not None
1173
+ and (recipient := self.pending_message.metadata.recipient) != ""
1174
+ and recipient not in (e.name, self.name)
1175
+ )
820
1176
 
821
1177
  def _can_respond(self, e: Responder) -> bool:
822
1178
  if self.pending_sender == e:
1179
+ # Responder cannot respond to its own message
823
1180
  return False
824
1181
  if self.pending_message is None:
825
1182
  return True
826
- if self.pending_message.metadata.block == e:
827
- # the entity should only be blocked at the first try;
828
- # Remove the block so it does not block the entity forever
829
- self.pending_message.metadata.block = None
830
- return False
831
1183
  if self._recipient_mismatch(e):
1184
+ # Cannot respond if not addressed to this entity
832
1185
  return False
833
- return self.pending_message is None or self.pending_message.metadata.block != e
1186
+ return self.pending_message.metadata.block != e
834
1187
 
835
1188
  def set_color_log(self, enable: bool = True) -> None:
836
1189
  """