langroid 0.1.139__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 (97) hide show
  1. langroid/__init__.py +70 -0
  2. langroid/agent/__init__.py +22 -0
  3. langroid/agent/base.py +120 -33
  4. langroid/agent/batch.py +134 -35
  5. langroid/agent/callbacks/__init__.py +0 -0
  6. langroid/agent/callbacks/chainlit.py +608 -0
  7. langroid/agent/chat_agent.py +164 -100
  8. langroid/agent/chat_document.py +19 -2
  9. langroid/agent/openai_assistant.py +20 -10
  10. langroid/agent/special/__init__.py +33 -10
  11. langroid/agent/special/doc_chat_agent.py +521 -108
  12. langroid/agent/special/lance_doc_chat_agent.py +258 -0
  13. langroid/agent/special/lance_rag/__init__.py +9 -0
  14. langroid/agent/special/lance_rag/critic_agent.py +136 -0
  15. langroid/agent/special/lance_rag/lance_rag_task.py +80 -0
  16. langroid/agent/special/lance_rag/query_planner_agent.py +180 -0
  17. langroid/agent/special/lance_tools.py +44 -0
  18. langroid/agent/special/neo4j/__init__.py +0 -0
  19. langroid/agent/special/neo4j/csv_kg_chat.py +174 -0
  20. langroid/agent/special/neo4j/neo4j_chat_agent.py +370 -0
  21. langroid/agent/special/neo4j/utils/__init__.py +0 -0
  22. langroid/agent/special/neo4j/utils/system_message.py +46 -0
  23. langroid/agent/special/relevance_extractor_agent.py +23 -7
  24. langroid/agent/special/retriever_agent.py +29 -174
  25. langroid/agent/special/sql/__init__.py +7 -0
  26. langroid/agent/special/sql/sql_chat_agent.py +47 -23
  27. langroid/agent/special/sql/utils/__init__.py +11 -0
  28. langroid/agent/special/sql/utils/description_extractors.py +95 -46
  29. langroid/agent/special/sql/utils/populate_metadata.py +28 -21
  30. langroid/agent/special/table_chat_agent.py +43 -9
  31. langroid/agent/task.py +423 -114
  32. langroid/agent/tool_message.py +67 -10
  33. langroid/agent/tools/__init__.py +8 -0
  34. langroid/agent/tools/duckduckgo_search_tool.py +66 -0
  35. langroid/agent/tools/google_search_tool.py +11 -0
  36. langroid/agent/tools/metaphor_search_tool.py +67 -0
  37. langroid/agent/tools/recipient_tool.py +6 -24
  38. langroid/agent/tools/sciphi_search_rag_tool.py +79 -0
  39. langroid/cachedb/__init__.py +6 -0
  40. langroid/embedding_models/__init__.py +24 -0
  41. langroid/embedding_models/base.py +9 -1
  42. langroid/embedding_models/models.py +117 -17
  43. langroid/embedding_models/protoc/embeddings.proto +19 -0
  44. langroid/embedding_models/protoc/embeddings_pb2.py +33 -0
  45. langroid/embedding_models/protoc/embeddings_pb2.pyi +50 -0
  46. langroid/embedding_models/protoc/embeddings_pb2_grpc.py +79 -0
  47. langroid/embedding_models/remote_embeds.py +153 -0
  48. langroid/language_models/__init__.py +22 -0
  49. langroid/language_models/azure_openai.py +47 -4
  50. langroid/language_models/base.py +26 -10
  51. langroid/language_models/config.py +5 -0
  52. langroid/language_models/openai_gpt.py +407 -121
  53. langroid/language_models/prompt_formatter/__init__.py +9 -0
  54. langroid/language_models/prompt_formatter/base.py +4 -6
  55. langroid/language_models/prompt_formatter/hf_formatter.py +135 -0
  56. langroid/language_models/utils.py +10 -9
  57. langroid/mytypes.py +10 -4
  58. langroid/parsing/__init__.py +33 -1
  59. langroid/parsing/document_parser.py +259 -63
  60. langroid/parsing/image_text.py +32 -0
  61. langroid/parsing/parse_json.py +143 -0
  62. langroid/parsing/parser.py +20 -7
  63. langroid/parsing/repo_loader.py +108 -46
  64. langroid/parsing/search.py +8 -0
  65. langroid/parsing/table_loader.py +44 -0
  66. langroid/parsing/url_loader.py +59 -13
  67. langroid/parsing/urls.py +18 -9
  68. langroid/parsing/utils.py +130 -9
  69. langroid/parsing/web_search.py +73 -0
  70. langroid/prompts/__init__.py +7 -0
  71. langroid/prompts/chat-gpt4-system-prompt.md +68 -0
  72. langroid/prompts/prompts_config.py +1 -1
  73. langroid/utils/__init__.py +10 -0
  74. langroid/utils/algorithms/__init__.py +3 -0
  75. langroid/utils/configuration.py +0 -1
  76. langroid/utils/constants.py +4 -0
  77. langroid/utils/logging.py +2 -5
  78. langroid/utils/output/__init__.py +15 -2
  79. langroid/utils/output/status.py +33 -0
  80. langroid/utils/pandas_utils.py +30 -0
  81. langroid/utils/pydantic_utils.py +446 -4
  82. langroid/utils/system.py +36 -1
  83. langroid/vector_store/__init__.py +34 -2
  84. langroid/vector_store/base.py +33 -2
  85. langroid/vector_store/chromadb.py +42 -13
  86. langroid/vector_store/lancedb.py +226 -60
  87. langroid/vector_store/meilisearch.py +7 -6
  88. langroid/vector_store/momento.py +3 -2
  89. langroid/vector_store/qdrantdb.py +82 -11
  90. {langroid-0.1.139.dist-info → langroid-0.1.219.dist-info}/METADATA +190 -129
  91. langroid-0.1.219.dist-info/RECORD +127 -0
  92. langroid/agent/special/recipient_validator_agent.py +0 -157
  93. langroid/parsing/json.py +0 -64
  94. langroid/utils/web/selenium_login.py +0 -36
  95. langroid-0.1.139.dist-info/RECORD +0 -103
  96. {langroid-0.1.139.dist-info → langroid-0.1.219.dist-info}/LICENSE +0 -0
  97. {langroid-0.1.139.dist-info → langroid-0.1.219.dist-info}/WHEEL +0 -0
langroid/agent/task.py CHANGED
@@ -2,9 +2,24 @@ from __future__ import annotations
2
2
 
3
3
  import copy
4
4
  import logging
5
- 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
+ )
6
20
 
7
21
  from rich import print
22
+ from rich.markup import escape
8
23
 
9
24
  from langroid.agent.base import Agent
10
25
  from langroid.agent.chat_agent import ChatAgent
@@ -14,8 +29,9 @@ from langroid.agent.chat_document import (
14
29
  ChatDocument,
15
30
  )
16
31
  from langroid.mytypes import Entity
32
+ from langroid.parsing.parse_json import extract_top_level_json
17
33
  from langroid.utils.configuration import settings
18
- 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
19
35
  from langroid.utils.logging import RichFileLogger, setup_file_logger
20
36
 
21
37
  logger = logging.getLogger(__name__)
@@ -23,6 +39,10 @@ logger = logging.getLogger(__name__)
23
39
  Responder = Entity | Type["Task"]
24
40
 
25
41
 
42
+ def noop_fn(*args: List[Any], **kwargs: Dict[str, Any]) -> None:
43
+ pass
44
+
45
+
26
46
  class Task:
27
47
  """
28
48
  A `Task` wraps an `Agent` object, and sets up the `Agent`'s goals and instructions.
@@ -55,7 +75,7 @@ class Task:
55
75
 
56
76
  def __init__(
57
77
  self,
58
- agent: Agent,
78
+ agent: Optional[Agent] = None,
59
79
  name: str = "",
60
80
  llm_delegate: bool = False,
61
81
  single_round: bool = False,
@@ -64,8 +84,12 @@ class Task:
64
84
  restart: bool = True,
65
85
  default_human_response: Optional[str] = None,
66
86
  interactive: bool = True,
67
- only_user_quits_root: bool = True,
87
+ only_user_quits_root: bool = False,
68
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] = [],
69
93
  ):
70
94
  """
71
95
  A task to be performed by an agent.
@@ -73,12 +97,17 @@ class Task:
73
97
  Args:
74
98
  agent (Agent): agent associated with the task
75
99
  name (str): name of the task
76
- 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,
77
104
  the "controlling entity" is the one "seeking" responses to its queries,
78
105
  and has a goal it is aiming to achieve. The "controlling entity" is
79
106
  either the LLM or the USER. (Note within a Task there is just one
80
107
  LLM, and all other entities are proxies of the "User" entity).
81
- 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,
82
111
  and subsequent response by non-controller. If false, runs for the
83
112
  specified number of turns in `run`, or until `done()` is true.
84
113
  One run of step() is considered a "turn".
@@ -87,16 +116,50 @@ class Task:
87
116
  restart (bool): if true, resets the agent's message history
88
117
  default_human_response (str): default response from user; useful for
89
118
  testing, to avoid interactive input from user.
119
+ [Instead of this, setting `interactive` usually suffices]
90
120
  interactive (bool): if true, wait for human input after each non-human
91
121
  response (prevents infinite loop of non-human responses).
92
122
  Default is true. If false, then `default_human_response` is set to ""
93
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]
94
126
  erase_substeps (bool): if true, when task completes, erase intermediate
95
127
  conversation with subtasks from this agent's `message_history`, and also
96
128
  erase all subtask agents' `message_history`.
97
129
  Note: erasing can reduce prompt sizes, but results in repetitive
98
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.
99
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
+
100
163
  if isinstance(agent, ChatAgent) and len(agent.message_history) == 0 or restart:
101
164
  agent = cast(ChatAgent, agent)
102
165
  agent.clear_history(0)
@@ -112,20 +175,39 @@ class Task:
112
175
  self.tsv_logger: None | logging.Logger = None
113
176
  self.color_log: bool = False if settings.notebook else True
114
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
115
189
  self.name = name or agent.config.name
190
+ self.value: str = self.name
116
191
  self.default_human_response = default_human_response
192
+ if default_human_response is not None and default_human_response == "":
193
+ interactive = False
117
194
  self.interactive = interactive
118
195
  self.message_history_idx = -1
119
- if not interactive:
120
- default_human_response = ""
196
+ if interactive:
197
+ only_user_quits_root = True
198
+ else:
199
+ default_human_response = default_human_response or ""
121
200
  only_user_quits_root = False
122
201
  if default_human_response is not None:
123
202
  self.agent.default_human_response = default_human_response
203
+ if self.interactive:
204
+ self.agent.default_human_response = None
124
205
  self.only_user_quits_root = only_user_quits_root
125
206
  # set to True if we want to collapse multi-turn conversation with sub-tasks into
126
207
  # just the first outgoing message and last incoming message.
127
208
  # Note this also completely erases sub-task agents' message_history.
128
209
  self.erase_substeps = erase_substeps
210
+ self.allow_null_result = allow_null_result
129
211
 
130
212
  agent_entity_responders = agent.entity_responders()
131
213
  agent_entity_responders_async = agent.entity_responders_async()
@@ -178,11 +260,7 @@ class Task:
178
260
  Returns a copy of this task, with a new agent.
179
261
  """
180
262
  assert isinstance(self.agent, ChatAgent), "Task clone only works for ChatAgent"
181
-
182
- agent_cls = type(self.agent)
183
- config_copy = copy.deepcopy(self.agent.config)
184
- config_copy.name = f"{config_copy.name}-{i}"
185
- agent: ChatAgent = agent_cls(config_copy)
263
+ agent: ChatAgent = self.agent.clone(i)
186
264
  return Task(
187
265
  agent,
188
266
  name=self.name + f"-{i}",
@@ -193,8 +271,11 @@ class Task:
193
271
  restart=False,
194
272
  default_human_response=self.default_human_response,
195
273
  interactive=self.interactive,
196
- only_user_quits_root=self.only_user_quits_root,
197
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],
198
279
  )
199
280
 
200
281
  def __repr__(self) -> str:
@@ -297,7 +378,8 @@ class Task:
297
378
  ) -> Optional[ChatDocument]:
298
379
  """Synchronous version of `run_async()`.
299
380
  See `run_async()` for details."""
300
-
381
+ self.task_progress = False
382
+ self.n_stalled_steps = 0
301
383
  assert (
302
384
  msg is None or isinstance(msg, str) or isinstance(msg, ChatDocument)
303
385
  ), f"msg arg in Task.run() must be None, str, or ChatDocument, not {type(msg)}"
@@ -361,7 +443,7 @@ class Task:
361
443
  # have come from another LLM), as far as this agent is concerned, the initial
362
444
  # message can be considered to be from the USER
363
445
  # (from the POV of this agent's LLM).
364
-
446
+ self.task_progress = False
365
447
  if (
366
448
  isinstance(msg, ChatDocument)
367
449
  and msg.metadata.recipient != ""
@@ -458,8 +540,11 @@ class Task:
458
540
  def step(self, turns: int = -1) -> ChatDocument | None:
459
541
  """
460
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.
461
545
  """
462
- result = None
546
+ self.is_done = False
547
+ self.step_progress = False
463
548
  parent = self.pending_message
464
549
  recipient = (
465
550
  ""
@@ -475,16 +560,30 @@ class Task:
475
560
  sender_name=Entity.AGENT,
476
561
  ),
477
562
  )
478
- self._process_responder_result(Entity.AGENT, parent, error_doc)
563
+ self._process_valid_responder_result(Entity.AGENT, parent, error_doc)
479
564
  return error_doc
480
565
 
481
566
  responders: List[Responder] = self.non_human_responders.copy()
482
- if Entity.USER in self.responders and not self.human_tried:
483
- # give human first chance if they haven't been tried in last step:
484
- # 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
+
485
582
  responders.insert(0, Entity.USER)
486
583
 
584
+ found_response = False
487
585
  for r in responders:
586
+ self.is_pass_thru = False
488
587
  if not self._can_respond(r):
489
588
  # create dummy msg for logging
490
589
  log_doc = ChatDocument(
@@ -500,10 +599,19 @@ class Task:
500
599
  continue
501
600
  self.human_tried = r == Entity.USER
502
601
  result = self.response(r, turns)
503
- is_break = self._process_responder_result(r, parent, result)
504
- 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)
505
608
  break
506
- if not self.valid(result):
609
+ else:
610
+ self.log_message(r, result)
611
+ if self.is_done:
612
+ # skip trying other responders in this step
613
+ break
614
+ if not found_response:
507
615
  self._process_invalid_step_result(parent)
508
616
  self._show_pending_message_if_debug()
509
617
  return self.pending_message
@@ -529,7 +637,8 @@ class Task:
529
637
  other use-cases, e.g. where we want to run a task step by step in a
530
638
  different context.
531
639
  """
532
- result = None
640
+ self.is_done = False
641
+ self.step_progress = False
533
642
  parent = self.pending_message
534
643
  recipient = (
535
644
  ""
@@ -545,15 +654,27 @@ class Task:
545
654
  sender_name=Entity.AGENT,
546
655
  ),
547
656
  )
548
- self._process_responder_result(Entity.AGENT, parent, error_doc)
657
+ self._process_valid_responder_result(Entity.AGENT, parent, error_doc)
549
658
  return error_doc
550
659
 
551
660
  responders: List[Responder] = self.non_human_responders_async.copy()
552
- if Entity.USER in self.responders_async and not self.human_tried:
553
- # give human first chance if they haven't been tried in last step:
554
- # 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.
555
675
  responders.insert(0, Entity.USER)
556
676
 
677
+ found_response = False
557
678
  for r in responders:
558
679
  if not self._can_respond(r):
559
680
  # create dummy msg for logging
@@ -570,69 +691,117 @@ class Task:
570
691
  continue
571
692
  self.human_tried = r == Entity.USER
572
693
  result = await self.response_async(r, turns)
573
- is_break = self._process_responder_result(r, parent, result)
574
- 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
575
705
  break
576
- if not self.valid(result):
706
+ if not found_response:
577
707
  self._process_invalid_step_result(parent)
578
708
  self._show_pending_message_if_debug()
579
709
  return self.pending_message
580
710
 
581
- def _process_responder_result(
711
+ def _process_valid_responder_result(
582
712
  self,
583
713
  r: Responder,
584
714
  parent: ChatDocument | None,
585
- result: ChatDocument | None,
586
- ) -> bool:
587
- """Processes result and returns whether to break out
588
- of the loop in `step` or `step_async`."""
589
-
590
- if self.valid(result):
591
- 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:
592
725
  self.pending_sender = r
593
- if result.metadata.parent_responder is not None and not isinstance(
594
- r, Entity
595
- ):
596
- # This code is only used by the now-deprecated RecipientValidatorAgent.
597
- # (which has been deprecated in favor of using the RecipientTool).
598
- # When result is from a sub-task, and `result.metadata` contains
599
- # a non-null `parent_responder`, pretend this result was
600
- # from the parent_responder, by setting `self.pending_sender`.
601
- self.pending_sender = result.metadata.parent_responder
602
- # Since we've just used the "pretend responder",
603
- # clear out the pretend responder in metadata
604
- # (so that it doesn't get used again)
605
- result.metadata.parent_responder = None
606
- result.metadata.parent = parent
607
- old_attachment = (
608
- self.pending_message.attachment if self.pending_message else None
609
- )
726
+ result.metadata.parent = parent
727
+ if not self.is_pass_thru:
610
728
  self.pending_message = result
611
- # if result has no attachment, preserve the old attachment
612
- if result.attachment is None:
613
- self.pending_message.attachment = old_attachment
614
- self.log_message(self.pending_sender, result, mark=True)
615
- 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
616
734
  else:
617
- self.log_message(r, result)
618
- return False
735
+ # reset stuck counter since we made progress
736
+ self.n_stalled_steps = 0
619
737
 
620
738
  def _process_invalid_step_result(self, parent: ChatDocument | None) -> None:
621
- responder = Entity.LLM if self.pending_sender == Entity.USER else Entity.USER
622
- self.pending_message = ChatDocument(
623
- content=NO_ANSWER,
624
- metadata=ChatDocMetaData(sender=responder, parent=parent),
625
- )
626
- 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
627
760
  self.log_message(self.pending_sender, self.pending_message, mark=True)
628
761
 
629
762
  def _show_pending_message_if_debug(self) -> None:
630
763
  if self.pending_message is None:
631
764
  return
632
765
  if settings.debug:
633
- sender_str = str(self.pending_sender)
634
- msg_str = str(self.pending_message)
635
- 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
636
805
 
637
806
  def response(
638
807
  self,
@@ -644,15 +813,57 @@ class Task:
644
813
  """
645
814
  if isinstance(e, Task):
646
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)
647
818
  result = e.run(
648
819
  self.pending_message,
649
820
  turns=actual_turns,
650
821
  caller=self,
651
822
  )
652
- 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
+ )
653
830
  else:
654
831
  response_fn = self._entity_responder_map[cast(Entity, e)]
655
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:
656
867
  return result
657
868
 
658
869
  async def response_async(
@@ -677,16 +888,24 @@ class Task:
677
888
  """
678
889
  if isinstance(e, Task):
679
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)
680
893
  result = await e.run_async(
681
894
  self.pending_message,
682
895
  turns=actual_turns,
683
896
  caller=self,
684
897
  )
685
- 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
+ )
686
905
  else:
687
906
  response_fn = self._entity_responder_async_map[cast(Entity, e)]
688
907
  result = await response_fn(self.pending_message)
689
- return result
908
+ return self._process_result_routing(result)
690
909
 
691
910
  def result(self) -> ChatDocument:
692
911
  """
@@ -702,7 +921,7 @@ class Task:
702
921
  # assuming it is of the form "DONE: <content>"
703
922
  content = content.replace(DONE, "").strip()
704
923
  fun_call = result_msg.function_call if result_msg else None
705
- attachment = result_msg.attachment if result_msg else None
924
+ tool_messages = result_msg.tool_messages if result_msg else []
706
925
  block = result_msg.metadata.block if result_msg else None
707
926
  recipient = result_msg.metadata.recipient if result_msg else None
708
927
  responder = result_msg.metadata.parent_responder if result_msg else None
@@ -714,7 +933,7 @@ class Task:
714
933
  return ChatDocument(
715
934
  content=content,
716
935
  function_call=fun_call,
717
- attachment=attachment,
936
+ tool_messages=tool_messages,
718
937
  metadata=ChatDocMetaData(
719
938
  source=Entity.USER,
720
939
  sender=Entity.USER,
@@ -726,57 +945,152 @@ class Task:
726
945
  ),
727
946
  )
728
947
 
729
- 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:
730
1024
  """
731
1025
  Check if task is done. This is the default behavior.
732
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.
733
1031
  Returns:
734
1032
  bool: True if task is done, False otherwise
735
1033
  """
1034
+ result = result or self.pending_message
736
1035
  user_quit = (
737
- self.pending_message is not None
738
- and self.pending_message.content in USER_QUIT
739
- 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
740
1039
  )
741
1040
  if self._level == 0 and self.only_user_quits_root:
742
1041
  # for top-level task, only user can quit out
743
1042
  return user_quit
744
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
+
745
1054
  return (
746
1055
  # no valid response from any entity/agent in current turn
747
- self.pending_message is None
748
- # LLM decided task is done
749
- or DONE in self.pending_message.content
1056
+ result is None
1057
+ # An entity decided task is done
1058
+ or DONE in result.content
750
1059
  or ( # current task is addressing message to caller task
751
1060
  self.caller is not None
752
1061
  and self.caller.name != ""
753
- and self.pending_message.metadata.recipient == self.caller.name
754
- )
755
- or (
756
- # Task controller is "stuck", has nothing to say
757
- NO_ANSWER in self.pending_message.content
758
- and self.pending_message.metadata.sender == self.controller
1062
+ and result.metadata.recipient == self.caller.name
759
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
+ # )
760
1069
  or user_quit
761
1070
  )
762
1071
 
763
- def valid(self, result: Optional[ChatDocument]) -> bool:
1072
+ def valid(
1073
+ self,
1074
+ result: Optional[ChatDocument],
1075
+ r: Responder,
1076
+ ) -> bool:
764
1077
  """
765
- Is the result from an entity or sub-task such that we can stop searching
766
- 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?
767
1080
  """
768
1081
  # TODO caution we should ensure that no handler method (tool) returns simply
769
1082
  # an empty string (e.g when showing contents of an empty file), since that
770
1083
  # would be considered an invalid response, and other responders will wrongly
771
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
772
1090
  return (
773
1091
  result is not None
774
- and (result.content != "" or result.function_call is not None)
775
- and ( # if NO_ANSWER is from controller, then it means
776
- # controller is stuck and we are done with task loop
777
- NO_ANSWER not in result.content
778
- or result.metadata.sender == self.controller
779
- )
1092
+ and not self._is_empty_message(result)
1093
+ and result.content.strip() != NO_ANSWER
780
1094
  )
781
1095
 
782
1096
  def log_message(
@@ -853,28 +1167,23 @@ class Task:
853
1167
  """
854
1168
  Is the recipient explicitly specified and does not match responder "e" ?
855
1169
  """
856
- if self.pending_message is None:
857
- return False
858
- recipient = self.pending_message.metadata.recipient
859
- if recipient == "":
860
- return False
861
- # LLM-specified recipient could be an entity such as USER or AGENT,
862
- # or the name of another task.
863
- 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
+ )
864
1176
 
865
1177
  def _can_respond(self, e: Responder) -> bool:
866
1178
  if self.pending_sender == e:
1179
+ # Responder cannot respond to its own message
867
1180
  return False
868
1181
  if self.pending_message is None:
869
1182
  return True
870
- if self.pending_message.metadata.block == e:
871
- # the entity should only be blocked at the first try;
872
- # Remove the block so it does not block the entity forever
873
- self.pending_message.metadata.block = None
874
- return False
875
1183
  if self._recipient_mismatch(e):
1184
+ # Cannot respond if not addressed to this entity
876
1185
  return False
877
- return self.pending_message is None or self.pending_message.metadata.block != e
1186
+ return self.pending_message.metadata.block != e
878
1187
 
879
1188
  def set_color_log(self, enable: bool = True) -> None:
880
1189
  """