langroid 0.2.0__py3-none-any.whl → 0.2.3__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
@@ -344,7 +344,7 @@ class Agent(ABC):
344
344
  return results
345
345
  if not settings.quiet:
346
346
  console.print(f"[red]{self.indent}", end="")
347
- print(f"[red]Agent: {results}")
347
+ print(f"[red]Agent: {escape(results)}")
348
348
  maybe_json = len(extract_top_level_json(results)) > 0
349
349
  self.callbacks.show_agent_response(
350
350
  content=results,
@@ -409,14 +409,12 @@ class Agent(ABC):
409
409
  isinstance(msg, ChatDocument) and msg.metadata.recipient == Entity.USER
410
410
  )
411
411
 
412
- interactive = (
413
- self.interactive if self.interactive is not None else settings.interactive
414
- )
415
- if self.default_human_response is not None and not need_human_response:
416
- # useful for automated testing
417
- user_msg = self.default_human_response
418
- elif not interactive and not need_human_response:
412
+ interactive = self.interactive or settings.interactive
413
+
414
+ if not interactive and not need_human_response:
419
415
  return None
416
+ elif self.default_human_response is not None:
417
+ user_msg = self.default_human_response
420
418
  else:
421
419
  if self.callbacks.get_user_response is not None:
422
420
  # ask user with empty prompt: no need for prompt
@@ -857,7 +857,7 @@ class ChatAgent(Agent):
857
857
  # we won't have citations yet, so we're done
858
858
  return
859
859
  if response.metadata.has_citation and not settings.quiet:
860
- print("[grey37]SOURCES:\n" + response.metadata.source + "[/grey37]")
860
+ print("[grey37]SOURCES:\n" + escape(response.metadata.source) + "[/grey37]")
861
861
  self.callbacks.show_llm_response(
862
862
  content=str(response.metadata.source),
863
863
  is_tool=False,
@@ -36,7 +36,8 @@ class StatusCode(str, Enum):
36
36
  STALLED = "STALLED"
37
37
  INF_LOOP = "INF_LOOP"
38
38
  KILL = "KILL"
39
- MAX_TURNS = "MAX_TURNS"
39
+ FIXED_TURNS = "FIXED_TURNS" # reached intended number of turns
40
+ MAX_TURNS = "MAX_TURNS" # hit max-turns limit
40
41
  MAX_COST = "MAX_COST"
41
42
  MAX_TOKENS = "MAX_TOKENS"
42
43
  TIMEOUT = "TIMEOUT"
langroid/agent/task.py CHANGED
@@ -5,6 +5,7 @@ import copy
5
5
  import logging
6
6
  import threading
7
7
  from collections import Counter, deque
8
+ from pathlib import Path
8
9
  from types import SimpleNamespace
9
10
  from typing import (
10
11
  Any,
@@ -39,6 +40,7 @@ from langroid.parsing.routing import parse_addressed_message
39
40
  from langroid.pydantic_v1 import BaseModel
40
41
  from langroid.utils.configuration import settings
41
42
  from langroid.utils.constants import (
43
+ AT, # regex for start of an addressed recipient e.g. "@"
42
44
  DONE,
43
45
  NO_ANSWER,
44
46
  PASS,
@@ -77,6 +79,7 @@ class TaskConfig(BaseModel):
77
79
  inf_loop_dominance_factor: float = 1.5
78
80
  inf_loop_wait_factor: int = 5
79
81
  restart_as_subtask: bool = False
82
+ logs_dir: str = "logs"
80
83
 
81
84
 
82
85
  class Task:
@@ -124,13 +127,14 @@ class Task:
124
127
  restart: bool = True,
125
128
  default_human_response: Optional[str] = None,
126
129
  interactive: bool = True,
127
- only_user_quits_root: bool = False,
130
+ only_user_quits_root: bool = True,
128
131
  erase_substeps: bool = False,
129
- allow_null_result: bool = True,
132
+ allow_null_result: bool = False,
130
133
  max_stalled_steps: int = 5,
131
134
  done_if_no_response: List[Responder] = [],
132
135
  done_if_response: List[Responder] = [],
133
136
  config: TaskConfig = TaskConfig(),
137
+ **kwargs: Any, # catch-all for any legacy params, for backwards compatibility
134
138
  ):
135
139
  """
136
140
  A task to be performed by an agent.
@@ -139,23 +143,29 @@ class Task:
139
143
  agent (Agent): agent associated with the task
140
144
  name (str): name of the task
141
145
  llm_delegate (bool):
142
- [Deprecated, not used; use `done_if_response`, `done_if_no_response`
143
- instead]
144
- Whether to delegate control to LLM; conceptually,
146
+ Whether to delegate "control" to LLM; conceptually,
145
147
  the "controlling entity" is the one "seeking" responses to its queries,
146
- and has a goal it is aiming to achieve. The "controlling entity" is
147
- either the LLM or the USER. (Note within a Task there is just one
148
+ and has a goal it is aiming to achieve, and decides when a task is done.
149
+ The "controlling entity" is either the LLM or the USER.
150
+ (Note within a Task there is just one
148
151
  LLM, and all other entities are proxies of the "User" entity).
152
+ See also: `done_if_response`, `done_if_no_response` for more granular
153
+ control of task termination.
149
154
  single_round (bool):
150
- [Deprecated: Use `done_if_response`, `done_if_no_response` instead].
151
- If true, task runs until one message by controller,
152
- and subsequent response by non-controller. If false, runs for the
153
- specified number of turns in `run`, or until `done()` is true.
155
+ If true, task runs until one message by "controller"
156
+ (i.e. LLM if `llm_delegate` is true, otherwise USER)
157
+ and subsequent response by non-controller [When a tool is involved,
158
+ this will not give intended results. See `done_if_response`,
159
+ `done_if_no_response` below].
160
+ termination]. If false, runs for the specified number of turns in
161
+ `run`, or until `done()` is true.
154
162
  One run of step() is considered a "turn".
163
+ See also: `done_if_response`, `done_if_no_response` for more granular
164
+ control of task termination.
155
165
  system_message (str): if not empty, overrides agent's system_message
156
166
  user_message (str): if not empty, overrides agent's user_message
157
167
  restart (bool): if true, resets the agent's message history *at every run*.
158
- default_human_response (str): default response from user; useful for
168
+ default_human_response (str|None): default response from user; useful for
159
169
  testing, to avoid interactive input from user.
160
170
  [Instead of this, setting `interactive` usually suffices]
161
171
  interactive (bool): if true, wait for human input after each non-human
@@ -166,18 +176,26 @@ class Task:
166
176
  case the system will wait for a user response. In other words, use
167
177
  `interactive=False` when you want a "largely non-interactive"
168
178
  run, with the exception of explicit user addressing.
169
- only_user_quits_root (bool): if true, only user can quit the root task.
170
- [This param is ignored & deprecated; Keeping for backward compatibility.
171
- Instead of this, setting `interactive` suffices]
179
+ only_user_quits_root (bool): if true, when interactive=True, only user can
180
+ quit the root task (Ignored when interactive=False).
172
181
  erase_substeps (bool): if true, when task completes, erase intermediate
173
182
  conversation with subtasks from this agent's `message_history`, and also
174
183
  erase all subtask agents' `message_history`.
175
184
  Note: erasing can reduce prompt sizes, but results in repetitive
176
185
  sub-task delegation.
177
- allow_null_result (bool): [Deprecated, may be removed in future.]
178
- If true, allow null (empty or NO_ANSWER)
179
- as the result of a step or overall task result.
180
- Optional, default is True.
186
+ allow_null_result (bool):
187
+ If true, create dummy NO_ANSWER response when no valid response is found
188
+ in a step.
189
+ Optional, default is False.
190
+ *Note:* In non-interactive mode, when this is set to True,
191
+ you can have a situation where an LLM generates (non-tool) text,
192
+ and no other responders have valid responses, and a "Null result"
193
+ is inserted as a dummy response from the User entity, so the LLM
194
+ will now respond to this Null result, and this will continue
195
+ until the LLM emits a DONE signal (if instructed to do so),
196
+ otherwise langroid detects a potential infinite loop after
197
+ a certain number of such steps (= `TaskConfig.inf_loop_wait_factor`)
198
+ and will raise an InfiniteLoopException.
181
199
  max_stalled_steps (int): task considered done after this many consecutive
182
200
  steps with no progress. Default is 3.
183
201
  done_if_no_response (List[Responder]): consider task done if NULL
@@ -234,36 +252,32 @@ class Task:
234
252
  self.tsv_logger: None | logging.Logger = None
235
253
  self.color_log: bool = False if settings.notebook else True
236
254
 
237
- self.step_progress = False # progress in current step?
238
255
  self.n_stalled_steps = 0 # how many consecutive steps with no progress?
256
+ # how many 2-step-apart alternations of no_answer step-result have we had,
257
+ # i.e. x1, N/A, x2, N/A, x3, N/A ...
258
+ self.n_no_answer_alternations = 0
259
+ self._no_answer_step: int = -1
260
+ self._step_idx = -1 # current step index
239
261
  self.max_stalled_steps = max_stalled_steps
240
262
  self.done_if_response = [r.value for r in done_if_response]
241
263
  self.done_if_no_response = [r.value for r in done_if_no_response]
242
264
  self.is_done = False # is task done (based on response)?
243
265
  self.is_pass_thru = False # is current response a pass-thru?
244
- self.task_progress = False # progress in current task (since run or run_async)?
245
266
  if name:
246
267
  # task name overrides name in agent config
247
268
  agent.config.name = name
248
269
  self.name = name or agent.config.name
249
270
  self.value: str = self.name
250
271
 
251
- if default_human_response is not None and default_human_response == "":
252
- interactive = False
253
- self.interactive = interactive
254
- self.agent.interactive = interactive
255
- self.message_history_idx = -1
256
- if interactive:
257
- only_user_quits_root = True
258
- else:
259
- default_human_response = default_human_response or ""
260
- only_user_quits_root = False
272
+ self.default_human_response = default_human_response
261
273
  if default_human_response is not None:
274
+ # only override agent's default_human_response if it is explicitly set
262
275
  self.agent.default_human_response = default_human_response
263
- self.default_human_response = default_human_response
264
- if self.interactive:
265
- self.agent.default_human_response = None
276
+ self.interactive = interactive
277
+ self.agent.interactive = interactive
266
278
  self.only_user_quits_root = only_user_quits_root
279
+ self.message_history_idx = -1
280
+
267
281
  # set to True if we want to collapse multi-turn conversation with sub-tasks into
268
282
  # just the first outgoing message and last incoming message.
269
283
  # Note this also completely erases sub-task agents' message_history.
@@ -300,17 +314,16 @@ class Task:
300
314
  self.turns = -1 # no limit
301
315
  self.llm_delegate = llm_delegate
302
316
  if llm_delegate:
303
- self.controller = Entity.LLM
304
317
  if self.single_round:
305
318
  # 0: User instructs (delegating to LLM);
306
- # 1: LLM asks;
319
+ # 1: LLM (as the Controller) asks;
307
320
  # 2: user replies.
308
321
  self.turns = 2
309
322
  else:
310
- self.controller = Entity.USER
311
323
  if self.single_round:
312
- self.turns = 1 # 0: User asks, 1: LLM replies.
313
-
324
+ # 0: User (as Controller) asks,
325
+ # 1: LLM replies.
326
+ self.turns = 1
314
327
  # other sub_tasks this task can delegate to
315
328
  self.sub_tasks: List[Task] = []
316
329
  self.caller: Task | None = None # which task called this task's `run` method
@@ -521,12 +534,18 @@ class Task:
521
534
  if self.caller is not None and self.caller.logger is not None:
522
535
  self.logger = self.caller.logger
523
536
  else:
524
- self.logger = RichFileLogger(f"logs/{self.name}.log", color=self.color_log)
537
+ self.logger = RichFileLogger(
538
+ str(Path(self.config.logs_dir) / f"{self.name}.log"),
539
+ color=self.color_log,
540
+ )
525
541
 
526
542
  if self.caller is not None and self.caller.tsv_logger is not None:
527
543
  self.tsv_logger = self.caller.tsv_logger
528
544
  else:
529
- self.tsv_logger = setup_file_logger("tsv_logger", f"logs/{self.name}.tsv")
545
+ self.tsv_logger = setup_file_logger(
546
+ "tsv_logger",
547
+ str(Path(self.config.logs_dir) / f"{self.name}.tsv"),
548
+ )
530
549
  header = ChatDocLoggerFields().tsv_header()
531
550
  self.tsv_logger.info(f" \tTask\tResponder\t{header}")
532
551
 
@@ -559,8 +578,10 @@ class Task:
559
578
  # so reset own agent and recursively for all sub-tasks
560
579
  self.reset_all_sub_tasks()
561
580
 
562
- self.task_progress = False
563
581
  self.n_stalled_steps = 0
582
+ self._no_answer_step = -1 # last step where the best explicit response was N/A
583
+ # how many N/A alternations have we had so far? (for Inf loop detection)
584
+ self.n_no_answer_alternations = 0
564
585
  self.max_cost = max_cost
565
586
  self.max_tokens = max_tokens
566
587
  self.session_id = session_id
@@ -588,6 +609,7 @@ class Task:
588
609
  turns = self.turns if turns < 0 else turns
589
610
  i = 0
590
611
  while True:
612
+ self._step_idx = i # used in step() below
591
613
  self.step()
592
614
  done, status = self.done()
593
615
  if done:
@@ -601,7 +623,17 @@ class Task:
601
623
  else max(turns, settings.max_turns)
602
624
  )
603
625
  if max_turns > 0 and i >= max_turns:
604
- status = StatusCode.MAX_TURNS
626
+ # Important to distinguish between:
627
+ # (a) intentional run for a
628
+ # fixed number of turns, where we expect the pending message
629
+ # at that stage to be the desired result, and
630
+ # (b) hitting max_turns limit, which is not intentional, and is an
631
+ # exception, resulting in a None task result
632
+ status = (
633
+ StatusCode.MAX_TURNS
634
+ if i == settings.max_turns
635
+ else StatusCode.FIXED_TURNS
636
+ )
605
637
  break
606
638
  if (
607
639
  self.config.inf_loop_cycle_len > 0
@@ -617,9 +649,7 @@ class Task:
617
649
  """
618
650
  )
619
651
 
620
- final_result = self.result()
621
- if final_result is not None:
622
- final_result.metadata.status = status
652
+ final_result = self.result(status)
623
653
  self._post_run_loop()
624
654
  return final_result
625
655
 
@@ -673,8 +703,10 @@ class Task:
673
703
  # so reset own agent and recursively for all sub-tasks
674
704
  self.reset_all_sub_tasks()
675
705
 
676
- self.task_progress = False
677
706
  self.n_stalled_steps = 0
707
+ self._no_answer_step = -1 # last step where the best explicit response was N/A
708
+ # how many N/A alternations have we had so far? (for Inf loop detection)
709
+ self.n_no_answer_alternations = 0
678
710
  self.max_cost = max_cost
679
711
  self.max_tokens = max_tokens
680
712
  self.session_id = session_id
@@ -698,6 +730,7 @@ class Task:
698
730
  turns = self.turns if turns < 0 else turns
699
731
  i = 0
700
732
  while True:
733
+ self._step_idx = i # used in step() below
701
734
  await self.step_async()
702
735
  await asyncio.sleep(0.01) # temp yield to avoid blocking
703
736
  done, status = self.done()
@@ -712,7 +745,17 @@ class Task:
712
745
  else max(turns, settings.max_turns)
713
746
  )
714
747
  if max_turns > 0 and i >= max_turns:
715
- status = StatusCode.MAX_TURNS
748
+ # Important to distinguish between:
749
+ # (a) intentional run for a
750
+ # fixed number of turns, where we expect the pending message
751
+ # at that stage to be the desired result, and
752
+ # (b) hitting max_turns limit, which is not intentional, and is an
753
+ # exception, resulting in a None task result
754
+ status = (
755
+ StatusCode.MAX_TURNS
756
+ if i == settings.max_turns
757
+ else StatusCode.FIXED_TURNS
758
+ )
716
759
  break
717
760
  if (
718
761
  self.config.inf_loop_cycle_len > 0
@@ -728,9 +771,7 @@ class Task:
728
771
  """
729
772
  )
730
773
 
731
- final_result = self.result()
732
- if final_result is not None:
733
- final_result.metadata.status = status
774
+ final_result = self.result(status)
734
775
  self._post_run_loop()
735
776
  return final_result
736
777
 
@@ -744,9 +785,6 @@ class Task:
744
785
  self.init(msg)
745
786
  # sets indentation to be printed prior to any output from agent
746
787
  self.agent.indent = self._indent
747
- if self.default_human_response is not None:
748
- self.agent.default_human_response = self.default_human_response
749
-
750
788
  self.message_history_idx = -1
751
789
  if isinstance(self.agent, ChatAgent):
752
790
  # mark where we are in the message history, so we can reset to this when
@@ -820,7 +858,6 @@ class Task:
820
858
  `step_async()`. Consider refactoring to avoid duplication.
821
859
  """
822
860
  self.is_done = False
823
- self.step_progress = False
824
861
  parent = self.pending_message
825
862
  recipient = (
826
863
  ""
@@ -860,6 +897,8 @@ class Task:
860
897
  responders.insert(0, Entity.USER)
861
898
 
862
899
  found_response = False
900
+ # (responder, result) from a responder who explicitly said NO_ANSWER
901
+ no_answer_response: None | Tuple[Responder, ChatDocument] = None
863
902
  for r in responders:
864
903
  self.is_pass_thru = False
865
904
  if not self._can_respond(r):
@@ -879,6 +918,8 @@ class Task:
879
918
  continue
880
919
  self.human_tried = r == Entity.USER
881
920
  result = self.response(r, turns)
921
+ if result and NO_ANSWER in result.content:
922
+ no_answer_response = (r, result)
882
923
  self.is_done = self._is_done_response(result, r)
883
924
  self.is_pass_thru = PASS in result.content if result else False
884
925
  if self.valid(result, r):
@@ -891,8 +932,15 @@ class Task:
891
932
  if self.is_done:
892
933
  # skip trying other responders in this step
893
934
  break
894
- if not found_response:
895
- self._process_invalid_step_result(parent)
935
+ if not found_response: # did not find a Non-NO_ANSWER response
936
+ if no_answer_response:
937
+ # even though there was no valid response from anyone in this step,
938
+ # if there was at least one who EXPLICITLY said NO_ANSWER, then
939
+ # we process that as a valid response.
940
+ r, result = no_answer_response
941
+ self._process_valid_responder_result(r, parent, result)
942
+ else:
943
+ self._process_invalid_step_result(parent)
896
944
  self._show_pending_message_if_debug()
897
945
  return self.pending_message
898
946
 
@@ -918,7 +966,6 @@ class Task:
918
966
  different context.
919
967
  """
920
968
  self.is_done = False
921
- self.step_progress = False
922
969
  parent = self.pending_message
923
970
  recipient = (
924
971
  ""
@@ -956,6 +1003,8 @@ class Task:
956
1003
  responders.insert(0, Entity.USER)
957
1004
 
958
1005
  found_response = False
1006
+ # (responder, result) from a responder who explicitly said NO_ANSWER
1007
+ no_answer_response: None | Tuple[Responder, ChatDocument] = None
959
1008
  for r in responders:
960
1009
  if not self._can_respond(r):
961
1010
  # create dummy msg for logging
@@ -974,6 +1023,8 @@ class Task:
974
1023
  continue
975
1024
  self.human_tried = r == Entity.USER
976
1025
  result = await self.response_async(r, turns)
1026
+ if result and NO_ANSWER in result.content:
1027
+ no_answer_response = (r, result)
977
1028
  self.is_done = self._is_done_response(result, r)
978
1029
  self.is_pass_thru = PASS in result.content if result else False
979
1030
  if self.valid(result, r):
@@ -987,10 +1038,32 @@ class Task:
987
1038
  # skip trying other responders in this step
988
1039
  break
989
1040
  if not found_response:
990
- self._process_invalid_step_result(parent)
1041
+ if no_answer_response:
1042
+ # even though there was no valid response from anyone in this step,
1043
+ # if there was at least one who EXPLICITLY said NO_ANSWER, then
1044
+ # we process that as a valid response.
1045
+ r, result = no_answer_response
1046
+ self._process_valid_responder_result(r, parent, result)
1047
+ else:
1048
+ self._process_invalid_step_result(parent)
991
1049
  self._show_pending_message_if_debug()
992
1050
  return self.pending_message
993
1051
 
1052
+ def _update_no_answer_vars(self, result: ChatDocument) -> None:
1053
+ """Update variables related to NO_ANSWER responses, to aid
1054
+ in alternating NO_ANSWER infinite-loop detection."""
1055
+
1056
+ if NO_ANSWER in result.content:
1057
+ if self._no_answer_step == self._step_idx - 2:
1058
+ # N/A two steps ago
1059
+ self.n_no_answer_alternations += 1
1060
+ else:
1061
+ # reset alternations counter
1062
+ self.n_no_answer_alternations = 0
1063
+
1064
+ # record the last step where the best explicit response was N/A
1065
+ self._no_answer_step = self._step_idx
1066
+
994
1067
  def _process_valid_responder_result(
995
1068
  self,
996
1069
  r: Responder,
@@ -999,6 +1072,8 @@ class Task:
999
1072
  ) -> None:
1000
1073
  """Processes valid result from a responder, during a step"""
1001
1074
 
1075
+ self._update_no_answer_vars(result)
1076
+
1002
1077
  # pending_sender is of type Responder,
1003
1078
  # i.e. it is either one of the agent's entities
1004
1079
  # OR a sub-task, that has produced a valid response.
@@ -1026,8 +1101,6 @@ class Task:
1026
1101
  parent.metadata.child_id = result.id()
1027
1102
 
1028
1103
  self.log_message(self.pending_sender, result, mark=True)
1029
- self.step_progress = True
1030
- self.task_progress = True
1031
1104
  if self.is_pass_thru:
1032
1105
  self.n_stalled_steps += 1
1033
1106
  else:
@@ -1049,11 +1122,13 @@ class Task:
1049
1122
  parent (ChatDocument|None): parent message of the current message
1050
1123
  """
1051
1124
  self.n_stalled_steps += 1
1052
- if (not self.task_progress or self.allow_null_result) and not self.is_pass_thru:
1053
- # There has been no progress at all in this task, so we
1054
- # update the pending_message to a dummy NO_ANSWER msg
1125
+ if self.allow_null_result and not self.is_pass_thru:
1126
+ # Null step-result is allowed, and we're not in a "pass-thru" situation,
1127
+ # so we update the pending_message to a dummy NO_ANSWER msg
1055
1128
  # from the entity 'opposite' to the current pending_sender,
1056
- # so we show "progress" and avoid getting stuck in an infinite loop.
1129
+ # so that the task can continue.
1130
+ # CAUTION: unless the LLM is instructed to signal DONE at an appropriate
1131
+ # time, this can result in an infinite loop.
1057
1132
  responder = (
1058
1133
  Entity.LLM if self.pending_sender == Entity.USER else Entity.USER
1059
1134
  )
@@ -1063,6 +1138,7 @@ class Task:
1063
1138
  metadata=ChatDocMetaData(sender=responder, parent_id=parent_id),
1064
1139
  )
1065
1140
  self.pending_sender = responder
1141
+ self._update_no_answer_vars(self.pending_message)
1066
1142
  self.log_message(self.pending_sender, self.pending_message, mark=True)
1067
1143
 
1068
1144
  def _show_pending_message_if_debug(self) -> None:
@@ -1092,7 +1168,9 @@ class Task:
1092
1168
  max_cost=self.max_cost,
1093
1169
  max_tokens=self.max_tokens,
1094
1170
  )
1095
- result_str = str(ChatDocument.to_LLMMessage(result))
1171
+ result_str = ( # only used by callback to display content and possible tool
1172
+ "NONE" if result is None else str(ChatDocument.to_LLMMessage(result))
1173
+ )
1096
1174
  maybe_tool = len(extract_top_level_json(result_str)) > 0
1097
1175
  self.callbacks.show_subtask_response(
1098
1176
  task=e,
@@ -1180,16 +1258,23 @@ class Task:
1180
1258
  result = await response_fn(self.pending_message)
1181
1259
  return self._process_result_routing(result)
1182
1260
 
1183
- def result(self) -> ChatDocument:
1261
+ def result(self, status: StatusCode | None = None) -> ChatDocument | None:
1184
1262
  """
1185
1263
  Get result of task. This is the default behavior.
1186
1264
  Derived classes can override this.
1187
1265
 
1188
1266
  Note the result of a task is returned as if it is from the User entity.
1189
1267
 
1268
+ Args:
1269
+ status (StatusCode): status of the task when it ended
1190
1270
  Returns:
1191
1271
  ChatDocument: result of task
1192
1272
  """
1273
+ if status in [StatusCode.STALLED, StatusCode.MAX_TURNS, StatusCode.INF_LOOP]:
1274
+ # In these case we don't know (and don't want to try to guess)
1275
+ # what the task result should be, so we return None
1276
+ return None
1277
+
1193
1278
  result_msg = self.pending_message
1194
1279
 
1195
1280
  content = result_msg.content if result_msg else ""
@@ -1201,7 +1286,6 @@ class Task:
1201
1286
  block = result_msg.metadata.block if result_msg else None
1202
1287
  recipient = result_msg.metadata.recipient if result_msg else ""
1203
1288
  tool_ids = result_msg.metadata.tool_ids if result_msg else []
1204
- status = result_msg.metadata.status if result_msg else None
1205
1289
 
1206
1290
  # regardless of which entity actually produced the result,
1207
1291
  # when we return the result, we set entity to USER
@@ -1214,7 +1298,7 @@ class Task:
1214
1298
  source=Entity.USER,
1215
1299
  sender=Entity.USER,
1216
1300
  block=block,
1217
- status=status,
1301
+ status=status or (result_msg.metadata.status if result_msg else None),
1218
1302
  sender_name=self.name,
1219
1303
  recipient=recipient,
1220
1304
  tool_ids=tool_ids,
@@ -1270,13 +1354,20 @@ class Task:
1270
1354
  def _maybe_infinite_loop(self) -> bool:
1271
1355
  """
1272
1356
  Detect possible infinite loop based on message frequencies.
1273
- NOTE: This only (attempts to) detect "exact" loops, i.e. a cycle
1274
- of messages that repeats exactly, e.g.
1357
+ NOTE: This detects two types of loops:
1358
+ - Alternating NO_ANSWER loops, specifically of the form
1359
+ x1 NO_ANSWER x2 NO_ANSWER x3 NO_ANSWER...
1360
+ (e.g. an LLM repeatedly saying something different, and another responder
1361
+ or sub-task saying NO_ANSWER -- i.e. "DO-NOT-KNOW")
1362
+
1363
+ - "exact" loops, i.e. a cycle of messages that repeats exactly, e.g.
1275
1364
  a r b i t r a t e r a t e r a t e r a t e ...
1276
1365
 
1277
- [It does not detect "approximate" loops, where the LLM is generating a
1278
- sequence of messages that are similar, but not exactly the same.]
1366
+ [It does not detect more general "approximate" loops, where two entities are
1367
+ responding to each other potentially forever, with (slightly) different
1368
+ messages each time]
1279
1369
 
1370
+ Here is the logic for the exact-loop detection:
1280
1371
  Intuition: when you look at a sufficiently long sequence with an m-message
1281
1372
  loop, then the frequencies of these m messages will "dominate" those
1282
1373
  of all other messages.
@@ -1294,6 +1385,9 @@ class Task:
1294
1385
  If the set of last (W * m) messages are the same as the
1295
1386
  set of m dominant messages, then we are likely in a loop.
1296
1387
  """
1388
+ if self.n_no_answer_alternations > self.config.inf_loop_wait_factor:
1389
+ return True
1390
+
1297
1391
  max_cycle_len = self.config.inf_loop_cycle_len
1298
1392
  if max_cycle_len <= 0:
1299
1393
  # no loop detection
@@ -1362,8 +1456,8 @@ class Task:
1362
1456
  and result.content in USER_QUIT_STRINGS
1363
1457
  and result.metadata.sender == Entity.USER
1364
1458
  )
1365
- if self._level == 0 and self.only_user_quits_root:
1366
- # for top-level task, only user can quit out
1459
+ if self._level == 0 and self.interactive and self.only_user_quits_root:
1460
+ # for top-level task, in interactive mode, only user can quit out
1367
1461
  return (user_quit, StatusCode.USER_QUIT if user_quit else StatusCode.OK)
1368
1462
 
1369
1463
  if self.is_done:
@@ -1405,11 +1499,6 @@ class Task:
1405
1499
  and self.caller.name != ""
1406
1500
  and result.metadata.recipient == self.caller.name
1407
1501
  )
1408
- # or (
1409
- # # Task controller is "stuck", has nothing to say
1410
- # NO_ANSWER in result.content
1411
- # and result.metadata.sender == self.controller
1412
- # )
1413
1502
  or user_quit
1414
1503
  )
1415
1504
  return (final, StatusCode.OK)
@@ -1591,7 +1680,6 @@ def parse_routing(
1591
1680
  return True, addressee, None
1592
1681
  else:
1593
1682
  return False, addressee, content_to_send
1594
- AT = "@"
1595
1683
  if (
1596
1684
  AT in content
1597
1685
  and (addressee_content := parse_addressed_message(content, AT))[0] is not None
@@ -82,8 +82,9 @@ class RewindTool(ToolMessage):
82
82
  cls(n=1, content="What are the 3 major causes of heart disease?"),
83
83
  (
84
84
  """
85
- I want to change my 2nd message to Bob, to say
86
- 'who wrote the book Grime and Banishment?'
85
+ Based on the conversation so far, I realize I would get a better
86
+ response from Bob if rephrase my 2nd message to him to:
87
+ 'Who wrote the book Grime and Banishment?'
87
88
  """,
88
89
  cls(n=2, content="who wrote the book 'Grime and Banishment'?"),
89
90
  ),
@@ -1,12 +1,16 @@
1
1
  """Mock Language Model for testing"""
2
2
 
3
- from typing import Dict, List, Optional, Union
3
+ from typing import Callable, Dict, List, Optional, Union
4
4
 
5
5
  import langroid.language_models as lm
6
6
  from langroid.language_models import LLMResponse
7
7
  from langroid.language_models.base import LanguageModel, LLMConfig
8
8
 
9
9
 
10
+ def none_fn(x: str) -> None | str:
11
+ return None
12
+
13
+
10
14
  class MockLMConfig(LLMConfig):
11
15
  """
12
16
  Mock Language Model Configuration.
@@ -17,7 +21,9 @@ class MockLMConfig(LLMConfig):
17
21
  """
18
22
 
19
23
  response_dict: Dict[str, str] = {}
24
+ response_fn: Callable[[str], None | str] = none_fn
20
25
  default_response: str = "Mock response"
26
+
21
27
  type: str = "mock"
22
28
 
23
29
 
@@ -27,6 +33,19 @@ class MockLM(LanguageModel):
27
33
  super().__init__(config)
28
34
  self.config: MockLMConfig = config
29
35
 
36
+ def _response(self, msg: str) -> LLMResponse:
37
+ # response is based on this fallback order:
38
+ # - response_dict
39
+ # - response_fn
40
+ # - default_response
41
+ return lm.LLMResponse(
42
+ message=self.config.response_dict.get(
43
+ msg,
44
+ self.config.response_fn(msg) or self.config.default_response,
45
+ ),
46
+ cached=False,
47
+ )
48
+
30
49
  def chat(
31
50
  self,
32
51
  messages: Union[str, List[lm.LLMMessage]],
@@ -38,13 +57,7 @@ class MockLM(LanguageModel):
38
57
  Mock chat function for testing
39
58
  """
40
59
  last_msg = messages[-1].content if isinstance(messages, list) else messages
41
- return lm.LLMResponse(
42
- message=self.config.response_dict.get(
43
- last_msg,
44
- self.config.default_response,
45
- ),
46
- cached=False,
47
- )
60
+ return self._response(last_msg)
48
61
 
49
62
  async def achat(
50
63
  self,
@@ -57,37 +70,19 @@ class MockLM(LanguageModel):
57
70
  Mock chat function for testing
58
71
  """
59
72
  last_msg = messages[-1].content if isinstance(messages, list) else messages
60
- return lm.LLMResponse(
61
- message=self.config.response_dict.get(
62
- last_msg,
63
- self.config.default_response,
64
- ),
65
- cached=False,
66
- )
73
+ return self._response(last_msg)
67
74
 
68
75
  def generate(self, prompt: str, max_tokens: int = 200) -> lm.LLMResponse:
69
76
  """
70
77
  Mock generate function for testing
71
78
  """
72
- return lm.LLMResponse(
73
- message=self.config.response_dict.get(
74
- prompt,
75
- self.config.default_response,
76
- ),
77
- cached=False,
78
- )
79
+ return self._response(prompt)
79
80
 
80
81
  async def agenerate(self, prompt: str, max_tokens: int = 200) -> LLMResponse:
81
82
  """
82
83
  Mock generate function for testing
83
84
  """
84
- return lm.LLMResponse(
85
- message=self.config.response_dict.get(
86
- prompt,
87
- self.config.default_response,
88
- ),
89
- cached=False,
90
- )
85
+ return self._response(prompt)
91
86
 
92
87
  def get_stream(self) -> bool:
93
88
  return False
@@ -13,10 +13,11 @@ class Colors(BaseModel):
13
13
  RESET: str = "\033[0m"
14
14
 
15
15
 
16
- USER_QUIT_STRINGS = ["q", "x", "quit", "exit", "bye"]
17
16
  NO_ANSWER = "DO-NOT-KNOW"
18
17
  DONE = "DONE"
18
+ USER_QUIT_STRINGS = ["q", "x", "quit", "exit", "bye", DONE]
19
19
  PASS = "__PASS__"
20
20
  PASS_TO = PASS + ":"
21
21
  SEND_TO = "SEND:"
22
22
  TOOL = "TOOL"
23
+ AT = "@"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: langroid
3
- Version: 0.2.0
3
+ Version: 0.2.3
4
4
  Summary: Harness LLMs with Multi-Agent Programming
5
5
  License: MIT
6
6
  Author: Prasad Chalasani
@@ -36,7 +36,7 @@ Provides-Extra: vecdbs
36
36
  Requires-Dist: aiohttp (>=3.9.1,<4.0.0)
37
37
  Requires-Dist: async-generator (>=1.10,<2.0)
38
38
  Requires-Dist: bs4 (>=0.0.1,<0.0.2)
39
- Requires-Dist: chainlit (>=1.0.400,<2.0.0) ; extra == "all" or extra == "chainlit"
39
+ Requires-Dist: chainlit (==1.1.202) ; extra == "all" or extra == "chainlit"
40
40
  Requires-Dist: chromadb (>=0.4.21,<=0.4.23) ; extra == "vecdbs" or extra == "all" or extra == "chromadb"
41
41
  Requires-Dist: colorlog (>=6.7.0,<7.0.0)
42
42
  Requires-Dist: docstring-parser (>=0.15,<0.16)
@@ -226,6 +226,10 @@ teacher_task.run()
226
226
  <details>
227
227
  <summary> <b>Click to expand</b></summary>
228
228
 
229
+ - **Jun 2024:**
230
+ - **0.2.0:** Improved lineage tracking, granular sub-task configs, and a new tool, `RewindTool`,
231
+ that lets an agent "rewind and redo" a past message (and all dependent messages are cleared out
232
+ thanks to the lineage tracking). Read notes [here](https://github.com/langroid/langroid/releases/tag/0.2.0).
229
233
  - **May 2024:**
230
234
  - **Slimmer langroid**: All document-parsers (i.e. pdf, doc, docx) and most
231
235
  vector-databases (except qdrant)
@@ -1,11 +1,11 @@
1
1
  langroid/__init__.py,sha256=z_fCOLQJPOw3LLRPBlFB5-2HyCjpPgQa4m4iY5Fvb8Y,1800
2
2
  langroid/agent/__init__.py,sha256=ll0Cubd2DZ-fsCMl7e10hf9ZjFGKzphfBco396IKITY,786
3
- langroid/agent/base.py,sha256=rqkf5FN1jO7IGqa_bvnQc37d8LZRal1RHVJe0Dvtlsc,37680
3
+ langroid/agent/base.py,sha256=eeYZ-NYbrepOjUVQS9K0nDhE8x2gKUNjgxFTA24mook,37560
4
4
  langroid/agent/batch.py,sha256=feRA_yRG768ElOQjrKEefcRv6Aefd_yY7qktuYUQDwc,10040
5
5
  langroid/agent/callbacks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
6
  langroid/agent/callbacks/chainlit.py,sha256=UKG2_v4ktfkEaGvdouVRHEqQejEYya2Rli8jrP65TmA,22055
7
- langroid/agent/chat_agent.py,sha256=eRTrTjGlu36gTbdBLbQ5M5EtBMIcdEc9bjgMLTa40oQ,41506
8
- langroid/agent/chat_document.py,sha256=8yH7o0aMVtUDHh3InpEErjhlY6t4Lr6KQzBrAKcYsEM,11141
7
+ langroid/agent/chat_agent.py,sha256=nO6Yx5WvFsul5RmTP-HCdzeQPhccmzU_mDcPNdkzQ-s,41514
8
+ langroid/agent/chat_document.py,sha256=MwtNABK28tfSzqCeQlxoauT8uPn8oldU7dlnrX8aQ10,11232
9
9
  langroid/agent/helpers.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
10
  langroid/agent/junk,sha256=LxfuuW7Cijsg0szAzT81OjWWv1PMNI-6w_-DspVIO2s,339
11
11
  langroid/agent/openai_assistant.py,sha256=rmGJD5n0eE7_O1EkPyXgHFMNGc3vb2GKweZMhzmRWvI,33068
@@ -32,7 +32,7 @@ langroid/agent/special/sql/utils/populate_metadata.py,sha256=1J22UsyEPKzwK0XlJZt
32
32
  langroid/agent/special/sql/utils/system_message.py,sha256=qKLHkvQWRQodTtPLPxr1GSLUYUFASZU8x-ybV67cB68,1885
33
33
  langroid/agent/special/sql/utils/tools.py,sha256=vFYysk6Vi7HJjII8B4RitA3pt_z3gkSglDNdhNVMiFc,1332
34
34
  langroid/agent/special/table_chat_agent.py,sha256=d9v2wsblaRx7oMnKhLV7uO_ujvk9gh59pSGvBXyeyNc,9659
35
- langroid/agent/task.py,sha256=ZTnG8h214FF3Vm54Eyl37j5OzKIHc313QbiUkMlofwM,67091
35
+ langroid/agent/task.py,sha256=ALqGmqzqUNaZB3u8jReBcsC8rRti3WqsN82EMm4CFAo,72189
36
36
  langroid/agent/tool_message.py,sha256=wIyZnUcZpxkiRPvM9O3MO3b5BBAdLEEan9kqPbvtApc,9743
37
37
  langroid/agent/tools/__init__.py,sha256=e-63cfwQNk_ftRKQwgDAJQK16QLbRVWDBILeXIc7wLk,402
38
38
  langroid/agent/tools/duckduckgo_search_tool.py,sha256=NhsCaGZkdv28nja7yveAhSK_w6l_Ftym8agbrdzqgfo,1935
@@ -42,7 +42,7 @@ langroid/agent/tools/google_search_tool.py,sha256=y7b-3FtgXf0lfF4AYxrZ3K5pH2dhid
42
42
  langroid/agent/tools/metaphor_search_tool.py,sha256=qj4gt453cLEX3EGW7nVzVu6X7LCdrwjSlcNY0qJW104,2489
43
43
  langroid/agent/tools/recipient_tool.py,sha256=NrLxIeQT-kbMv7AeYX0uqvGeMK4Q3fIDvG15OVzlgk8,9624
44
44
  langroid/agent/tools/retrieval_tool.py,sha256=2q2pfoYbZNfbWQ0McxrtmfF0ekGglIgRl-6uF26pa-E,871
45
- langroid/agent/tools/rewind_tool.py,sha256=aeu35_OjmCDTCgWH6nn8noXIC7ACD7-Rh-qh36wnBOg,5516
45
+ langroid/agent/tools/rewind_tool.py,sha256=G4DiXuOt2nQ2fU7qvtJMdLyyf-rK7RZwLsFxsAUfk-Y,5606
46
46
  langroid/agent/tools/run_python_code.py,sha256=BvoxYzzHijU-p4703n2iVlt5BCieR1oMSy50w0tQZAg,1787
47
47
  langroid/agent/tools/segment_extract_tool.py,sha256=__srZ_VGYLVOdPrITUM8S0HpmX4q7r5FHWMDdHdEv8w,1440
48
48
  langroid/agent_config.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -65,7 +65,7 @@ langroid/language_models/__init__.py,sha256=vrBtgR8Cq9UVfoI7nTms0IN7fd4y2JYpUP3G
65
65
  langroid/language_models/azure_openai.py,sha256=ncRCbKooqLVOY-PWQUIo9C3yTuKEFbAwyngXT_M4P7k,5989
66
66
  langroid/language_models/base.py,sha256=aVptuo_LpymIQFpJh836lcFCUpJNOV3ukxvQAQMCqFc,17426
67
67
  langroid/language_models/config.py,sha256=9Q8wk5a7RQr8LGMT_0WkpjY8S4ywK06SalVRjXlfCiI,378
68
- langroid/language_models/mock_lm.py,sha256=L0YqrrxLCePs_5MrK7rJ5-SajNxDtuVU_VvZVRfs9q4,2834
68
+ langroid/language_models/mock_lm.py,sha256=qdgj-wtbQBXlibo_0rIRfCt0hGTPRoxy1C4VjN6quI4,2707
69
69
  langroid/language_models/openai_gpt.py,sha256=RXnLKULuCSeDeUPQvaZ4naqJgMKcMZogCtRDLycd4j8,50714
70
70
  langroid/language_models/prompt_formatter/__init__.py,sha256=2-5cdE24XoFDhifOLl8yiscohil1ogbP1ECkYdBlBsk,372
71
71
  langroid/language_models/prompt_formatter/base.py,sha256=eDS1sgRNZVnoajwV_ZIha6cba5Dt8xjgzdRbPITwx3Q,1221
@@ -104,7 +104,7 @@ langroid/utils/__init__.py,sha256=Sruos2tB4G7Tn0vlblvYlX9PEGR0plI2uE0PJ4d_EC4,35
104
104
  langroid/utils/algorithms/__init__.py,sha256=WylYoZymA0fnzpB4vrsH_0n7WsoLhmuZq8qxsOCjUpM,41
105
105
  langroid/utils/algorithms/graph.py,sha256=JbdpPnUOhw4-D6O7ou101JLA3xPCD0Lr3qaPoFCaRfo,2866
106
106
  langroid/utils/configuration.py,sha256=A70LdvdMuunlLSGI1gBmBL5j6Jhz-1syNP8R4AdjqDc,3295
107
- langroid/utils/constants.py,sha256=eTiXfx8Nq2kmq0WChVLqV9C58UWju0NCIuW28sMgd5g,575
107
+ langroid/utils/constants.py,sha256=5WgyXjhRegNWB_BSYZsEZW-7mm-F_5bJPM4nuZ9qvNo,590
108
108
  langroid/utils/docker.py,sha256=kJQOLTgM0x9j9pgIIqp0dZNZCTvoUDhp6i8tYBq1Jr0,1105
109
109
  langroid/utils/globals.py,sha256=Az9dOFqR6n9CoTYSqa2kLikQWS0oCQ9DFQIQAnG-2q8,1355
110
110
  langroid/utils/llms/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -128,8 +128,8 @@ langroid/vector_store/meilisearch.py,sha256=6frB7GFWeWmeKzRfLZIvzRjllniZ1cYj3Hmh
128
128
  langroid/vector_store/momento.py,sha256=QaPzUnTwlswoawGB-paLtUPyLRvckFXLfLDfvbTzjNQ,10505
129
129
  langroid/vector_store/qdrant_cloud.py,sha256=3im4Mip0QXLkR6wiqVsjV1QvhSElfxdFSuDKddBDQ-4,188
130
130
  langroid/vector_store/qdrantdb.py,sha256=wYOuu5c2vIKn9ZgvTXcAiZXMpV8AOXEWFAzI8S8UP-0,16828
131
- pyproject.toml,sha256=dZYHxf2D_qc8g68xt4KgMoa7tuxbY_JpLn-Pd541kuw,6964
132
- langroid-0.2.0.dist-info/LICENSE,sha256=EgVbvA6VSYgUlvC3RvPKehSg7MFaxWDsFuzLOsPPfJg,1065
133
- langroid-0.2.0.dist-info/METADATA,sha256=vcctmhiBgiBI4LlzmbJFMfRG7nwjD4B1IZvnDu0XR_M,52823
134
- langroid-0.2.0.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
135
- langroid-0.2.0.dist-info/RECORD,,
131
+ pyproject.toml,sha256=MX6Rg0hLOiPlYpb9q4G0H0jLdqAjNWSI_tnKXB8mkuQ,6963
132
+ langroid-0.2.3.dist-info/LICENSE,sha256=EgVbvA6VSYgUlvC3RvPKehSg7MFaxWDsFuzLOsPPfJg,1065
133
+ langroid-0.2.3.dist-info/METADATA,sha256=1GD93aMG3wEtL7pE3NBXM1gxjyjEO0Og5XdtNGb3UGA,53146
134
+ langroid-0.2.3.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
135
+ langroid-0.2.3.dist-info/RECORD,,
pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "langroid"
3
- version = "0.2.0"
3
+ version = "0.2.3"
4
4
  description = "Harness LLMs with Multi-Agent Programming"
5
5
  authors = ["Prasad Chalasani <pchalasani@gmail.com>"]
6
6
  readme = "README.md"
@@ -23,7 +23,7 @@ pymysql = {version = "^1.1.0", optional = true}
23
23
  meilisearch-python-sdk = {version="^2.2.3", optional=true}
24
24
  litellm = {version = "^1.30.1", optional = true}
25
25
  metaphor-python = {version = "^0.1.23", optional = true}
26
- chainlit = {version = "^1.0.400", optional = true}
26
+ chainlit = {version = "1.1.202", optional = true}
27
27
  python-socketio = {version="^5.11.0", optional=true}
28
28
  neo4j = {version = "^5.14.1", optional = true}
29
29
  huggingface-hub = {version="^0.21.2", optional=true}