langroid 0.1.265__py3-none-any.whl → 0.2.2__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/task.py CHANGED
@@ -3,7 +3,9 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import copy
5
5
  import logging
6
+ import threading
6
7
  from collections import Counter, deque
8
+ from pathlib import Path
7
9
  from types import SimpleNamespace
8
10
  from typing import (
9
11
  Any,
@@ -13,7 +15,6 @@ from typing import (
13
15
  Dict,
14
16
  List,
15
17
  Optional,
16
- Set,
17
18
  Tuple,
18
19
  Type,
19
20
  cast,
@@ -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,
@@ -47,6 +49,7 @@ from langroid.utils.constants import (
47
49
  USER_QUIT_STRINGS,
48
50
  )
49
51
  from langroid.utils.logging import RichFileLogger, setup_file_logger
52
+ from langroid.utils.object_registry import scheduled_cleanup
50
53
  from langroid.utils.system import hash
51
54
 
52
55
  logger = logging.getLogger(__name__)
@@ -65,14 +68,18 @@ class TaskConfig(BaseModel):
65
68
  we have config classes for `Agent`, `ChatAgent`, `LanguageModel`, etc.
66
69
 
67
70
  Attributes:
68
- inf_loop_cycle_len: max exact-loop cycle length: 0 => no inf loop test
69
- inf_loop_dominance_factor: dominance factor for exact-loop detection
70
- inf_loop_wait_factor: wait this * cycle_len msgs before loop-check
71
+ inf_loop_cycle_len (int): max exact-loop cycle length: 0 => no inf loop test
72
+ inf_loop_dominance_factor (float): dominance factor for exact-loop detection
73
+ inf_loop_wait_factor (int): wait this * cycle_len msgs before loop-check
74
+ restart_subtask_run (bool): whether to restart *every* run of this task
75
+ when run as a subtask.
71
76
  """
72
77
 
73
78
  inf_loop_cycle_len: int = 10
74
79
  inf_loop_dominance_factor: float = 1.5
75
80
  inf_loop_wait_factor: int = 5
81
+ restart_as_subtask: bool = False
82
+ logs_dir: str = "logs"
76
83
 
77
84
 
78
85
  class Task:
@@ -107,6 +114,7 @@ class Task:
107
114
 
108
115
  # class variable called `cache` that is a RedisCache object
109
116
  _cache: RedisCache | None = None
117
+ _background_tasks_started: bool = False
110
118
 
111
119
  def __init__(
112
120
  self,
@@ -119,13 +127,14 @@ class Task:
119
127
  restart: bool = True,
120
128
  default_human_response: Optional[str] = None,
121
129
  interactive: bool = True,
122
- only_user_quits_root: bool = False,
130
+ only_user_quits_root: bool = True,
123
131
  erase_substeps: bool = False,
124
- allow_null_result: bool = True,
132
+ allow_null_result: bool = False,
125
133
  max_stalled_steps: int = 5,
126
134
  done_if_no_response: List[Responder] = [],
127
135
  done_if_response: List[Responder] = [],
128
136
  config: TaskConfig = TaskConfig(),
137
+ **kwargs: Any, # catch-all for any legacy params, for backwards compatibility
129
138
  ):
130
139
  """
131
140
  A task to be performed by an agent.
@@ -134,23 +143,29 @@ class Task:
134
143
  agent (Agent): agent associated with the task
135
144
  name (str): name of the task
136
145
  llm_delegate (bool):
137
- [Deprecated, not used; use `done_if_response`, `done_if_no_response`
138
- instead]
139
- Whether to delegate control to LLM; conceptually,
146
+ Whether to delegate "control" to LLM; conceptually,
140
147
  the "controlling entity" is the one "seeking" responses to its queries,
141
- and has a goal it is aiming to achieve. The "controlling entity" is
142
- 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
143
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.
144
154
  single_round (bool):
145
- [Deprecated: Use `done_if_response`, `done_if_no_response` instead].
146
- If true, task runs until one message by controller,
147
- and subsequent response by non-controller. If false, runs for the
148
- 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.
149
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.
150
165
  system_message (str): if not empty, overrides agent's system_message
151
166
  user_message (str): if not empty, overrides agent's user_message
152
- restart (bool): if true, resets the agent's message history
153
- default_human_response (str): default response from user; useful for
167
+ restart (bool): if true, resets the agent's message history *at every run*.
168
+ default_human_response (str|None): default response from user; useful for
154
169
  testing, to avoid interactive input from user.
155
170
  [Instead of this, setting `interactive` usually suffices]
156
171
  interactive (bool): if true, wait for human input after each non-human
@@ -161,18 +176,24 @@ class Task:
161
176
  case the system will wait for a user response. In other words, use
162
177
  `interactive=False` when you want a "largely non-interactive"
163
178
  run, with the exception of explicit user addressing.
164
- only_user_quits_root (bool): if true, only user can quit the root task.
165
- [This param is ignored & deprecated; Keeping for backward compatibility.
166
- 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).
167
181
  erase_substeps (bool): if true, when task completes, erase intermediate
168
182
  conversation with subtasks from this agent's `message_history`, and also
169
183
  erase all subtask agents' `message_history`.
170
184
  Note: erasing can reduce prompt sizes, but results in repetitive
171
185
  sub-task delegation.
172
- allow_null_result (bool): [Deprecated, may be removed in future.]
173
- If true, allow null (empty or NO_ANSWER)
174
- as the result of a step or overall task result.
175
- Optional, default is True.
186
+ allow_null_result (bool):
187
+ If true, allow null (empty or NO_ANSWER) as the result of a step or
188
+ overall task result.
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 it can result in an infinite loop.
176
197
  max_stalled_steps (int): task considered done after this many consecutive
177
198
  steps with no progress. Default is 3.
178
199
  done_if_no_response (List[Responder]): consider task done if NULL
@@ -187,6 +208,8 @@ class Task:
187
208
  set_parent_agent=noop_fn,
188
209
  )
189
210
  self.config = config
211
+ # how to behave as a sub-task; can be overriden by `add_sub_task()`
212
+ self.config_sub_task = copy.deepcopy(config)
190
213
  # counts of distinct pending messages in history,
191
214
  # to help detect (exact) infinite loops
192
215
  self.message_counter: Counter[str] = Counter()
@@ -208,54 +231,51 @@ class Task:
208
231
  the config may affect other agents using the same config.
209
232
  """
210
233
  )
211
-
234
+ self.restart = restart
235
+ agent = cast(ChatAgent, agent)
236
+ self.agent: ChatAgent = agent
212
237
  if isinstance(agent, ChatAgent) and len(agent.message_history) == 0 or restart:
213
- agent = cast(ChatAgent, agent)
214
- agent.clear_history(0)
215
- agent.clear_dialog()
238
+ self.agent.clear_history(0)
239
+ self.agent.clear_dialog()
216
240
  # possibly change the system and user messages
217
241
  if system_message:
218
242
  # we always have at least 1 task_message
219
- agent.set_system_message(system_message)
243
+ self.agent.set_system_message(system_message)
220
244
  if user_message:
221
- agent.set_user_message(user_message)
245
+ self.agent.set_user_message(user_message)
222
246
  self.max_cost: float = 0
223
247
  self.max_tokens: int = 0
224
248
  self.session_id: str = ""
225
249
  self.logger: None | RichFileLogger = None
226
250
  self.tsv_logger: None | logging.Logger = None
227
251
  self.color_log: bool = False if settings.notebook else True
228
- self.agent = agent
229
- self.step_progress = False # progress in current step?
252
+
230
253
  self.n_stalled_steps = 0 # how many consecutive steps with no progress?
254
+ # how many 2-step-apart alternations of no_answer step-result have we had,
255
+ # i.e. x1, N/A, x2, N/A, x3, N/A ...
256
+ self.n_no_answer_alternations = 0
257
+ self._no_answer_step: int = -1
258
+ self._step_idx = -1 # current step index
231
259
  self.max_stalled_steps = max_stalled_steps
232
260
  self.done_if_response = [r.value for r in done_if_response]
233
261
  self.done_if_no_response = [r.value for r in done_if_no_response]
234
262
  self.is_done = False # is task done (based on response)?
235
263
  self.is_pass_thru = False # is current response a pass-thru?
236
- self.task_progress = False # progress in current task (since run or run_async)?
237
264
  if name:
238
265
  # task name overrides name in agent config
239
266
  agent.config.name = name
240
267
  self.name = name or agent.config.name
241
268
  self.value: str = self.name
242
269
 
243
- if default_human_response is not None and default_human_response == "":
244
- interactive = False
245
- self.interactive = interactive
246
- self.agent.interactive = interactive
247
- self.message_history_idx = -1
248
- if interactive:
249
- only_user_quits_root = True
250
- else:
251
- default_human_response = default_human_response or ""
252
- only_user_quits_root = False
270
+ self.default_human_response = default_human_response
253
271
  if default_human_response is not None:
272
+ # only override agent's default_human_response if it is explicitly set
254
273
  self.agent.default_human_response = default_human_response
255
- self.default_human_response = default_human_response
256
- if self.interactive:
257
- self.agent.default_human_response = None
274
+ self.interactive = interactive
275
+ self.agent.interactive = interactive
258
276
  self.only_user_quits_root = only_user_quits_root
277
+ self.message_history_idx = -1
278
+
259
279
  # set to True if we want to collapse multi-turn conversation with sub-tasks into
260
280
  # just the first outgoing message and last incoming message.
261
281
  # Note this also completely erases sub-task agents' message_history.
@@ -292,20 +312,18 @@ class Task:
292
312
  self.turns = -1 # no limit
293
313
  self.llm_delegate = llm_delegate
294
314
  if llm_delegate:
295
- self.controller = Entity.LLM
296
315
  if self.single_round:
297
316
  # 0: User instructs (delegating to LLM);
298
- # 1: LLM asks;
317
+ # 1: LLM (as the Controller) asks;
299
318
  # 2: user replies.
300
319
  self.turns = 2
301
320
  else:
302
- self.controller = Entity.USER
303
321
  if self.single_round:
304
- self.turns = 1 # 0: User asks, 1: LLM replies.
305
-
322
+ # 0: User (as Controller) asks,
323
+ # 1: LLM replies.
324
+ self.turns = 1
306
325
  # other sub_tasks this task can delegate to
307
326
  self.sub_tasks: List[Task] = []
308
- self.parent_task: Set[Task] = set()
309
327
  self.caller: Task | None = None # which task called this task's `run` method
310
328
 
311
329
  def clone(self, i: int) -> "Task":
@@ -321,7 +339,7 @@ class Task:
321
339
  single_round=self.single_round,
322
340
  system_message=self.agent.system_message,
323
341
  user_message=self.agent.user_message,
324
- restart=False,
342
+ restart=self.restart,
325
343
  default_human_response=self.default_human_response,
326
344
  interactive=self.interactive,
327
345
  erase_substeps=self.erase_substeps,
@@ -338,6 +356,19 @@ class Task:
338
356
  cls._cache = RedisCache(RedisCacheConfig(fake=False))
339
357
  return cls._cache
340
358
 
359
+ @classmethod
360
+ def _start_background_tasks(cls) -> None:
361
+ """Start background object registry cleanup thread. NOT USED."""
362
+ if cls._background_tasks_started:
363
+ return
364
+ cls._background_tasks_started = True
365
+ cleanup_thread = threading.Thread(
366
+ target=scheduled_cleanup,
367
+ args=(600,),
368
+ daemon=True,
369
+ )
370
+ cleanup_thread.start()
371
+
341
372
  def __repr__(self) -> str:
342
373
  return f"{self.name}"
343
374
 
@@ -416,24 +447,37 @@ class Task:
416
447
  def _leave(self) -> str:
417
448
  return self._indent + "<<<"
418
449
 
419
- def add_sub_task(self, task: Task | List[Task]) -> None:
450
+ def add_sub_task(
451
+ self,
452
+ task: (
453
+ Task | List[Task] | Tuple[Task, TaskConfig] | List[Tuple[Task, TaskConfig]]
454
+ ),
455
+ ) -> None:
420
456
  """
421
457
  Add a sub-task (or list of subtasks) that this task can delegate
422
458
  (or fail-over) to. Note that the sequence of sub-tasks is important,
423
459
  since these are tried in order, as the parent task searches for a valid
424
- response.
460
+ response (unless a sub-task is explicitly addressed).
425
461
 
426
462
  Args:
427
- task (Task|List[Task]): sub-task(s) to add
463
+ task: A task, or list of tasks, or a tuple of task and task config,
464
+ or a list of tuples of task and task config.
465
+ These tasks are added as sub-tasks of the current task.
466
+ The task configs (if any) dictate how the tasks are run when
467
+ invoked as sub-tasks of other tasks. This allows users to specify
468
+ behavior applicable only in the context of a particular task-subtask
469
+ combination.
428
470
  """
429
-
430
471
  if isinstance(task, list):
431
472
  for t in task:
432
473
  self.add_sub_task(t)
433
474
  return
434
- assert isinstance(task, Task), f"added task must be a Task, not {type(task)}"
435
475
 
436
- task.parent_task.add(self) # add myself to set of parent tasks of `task`
476
+ if isinstance(task, tuple):
477
+ task, config = task
478
+ else:
479
+ config = TaskConfig()
480
+ task.config_sub_task = config
437
481
  self.sub_tasks.append(task)
438
482
  self.name_sub_task_map[task.name] = task
439
483
  self.responders.append(cast(Responder, task))
@@ -460,30 +504,59 @@ class Task:
460
504
  sender=Entity.USER,
461
505
  ),
462
506
  )
507
+ elif msg is None and len(self.agent.message_history) > 1:
508
+ # if agent has a history beyond system msg, set the
509
+ # pending message to the ChatDocument linked from
510
+ # last message in the history
511
+ last_agent_msg = self.agent.message_history[-1]
512
+ self.pending_message = ChatDocument.from_id(last_agent_msg.chat_document_id)
513
+ if self.pending_message is not None:
514
+ self.pending_sender = self.pending_message.metadata.sender
463
515
  else:
464
- self.pending_message = copy.deepcopy(msg)
516
+ if isinstance(msg, ChatDocument):
517
+ # carefully deep-copy: fresh metadata.id, register
518
+ # as new obj in registry
519
+ self.pending_message = ChatDocument.deepcopy(msg)
465
520
  if self.pending_message is not None and self.caller is not None:
466
521
  # msg may have come from `caller`, so we pretend this is from
467
522
  # the CURRENT task's USER entity
468
523
  self.pending_message.metadata.sender = Entity.USER
524
+ # update parent, child, agent pointers
525
+ if msg is not None:
526
+ msg.metadata.child_id = self.pending_message.metadata.id
527
+ self.pending_message.metadata.parent_id = msg.metadata.id
528
+ self.pending_message.metadata.agent_id = self.agent.id
469
529
 
470
530
  self._show_pending_message_if_debug()
471
531
 
472
532
  if self.caller is not None and self.caller.logger is not None:
473
533
  self.logger = self.caller.logger
474
534
  else:
475
- self.logger = RichFileLogger(f"logs/{self.name}.log", color=self.color_log)
535
+ self.logger = RichFileLogger(
536
+ str(Path(self.config.logs_dir) / f"{self.name}.log"),
537
+ color=self.color_log,
538
+ )
476
539
 
477
540
  if self.caller is not None and self.caller.tsv_logger is not None:
478
541
  self.tsv_logger = self.caller.tsv_logger
479
542
  else:
480
- self.tsv_logger = setup_file_logger("tsv_logger", f"logs/{self.name}.tsv")
543
+ self.tsv_logger = setup_file_logger(
544
+ "tsv_logger",
545
+ str(Path(self.config.logs_dir) / f"{self.name}.tsv"),
546
+ )
481
547
  header = ChatDocLoggerFields().tsv_header()
482
548
  self.tsv_logger.info(f" \tTask\tResponder\t{header}")
483
549
 
484
550
  self.log_message(Entity.USER, self.pending_message)
485
551
  return self.pending_message
486
552
 
553
+ def reset_all_sub_tasks(self) -> None:
554
+ """Recursively reset message history of own agent and all sub-tasks"""
555
+ self.agent.clear_history(0)
556
+ self.agent.clear_dialog()
557
+ for t in self.sub_tasks:
558
+ t.reset_all_sub_tasks()
559
+
487
560
  def run(
488
561
  self,
489
562
  msg: Optional[str | ChatDocument] = None,
@@ -495,8 +568,18 @@ class Task:
495
568
  ) -> Optional[ChatDocument]:
496
569
  """Synchronous version of `run_async()`.
497
570
  See `run_async()` for details."""
498
- self.task_progress = False
571
+ if (self.restart and caller is None) or (
572
+ self.config_sub_task.restart_as_subtask and caller is not None
573
+ ):
574
+ # We are either at top level, with restart = True, OR
575
+ # we are a sub-task with restart_as_subtask = True,
576
+ # so reset own agent and recursively for all sub-tasks
577
+ self.reset_all_sub_tasks()
578
+
499
579
  self.n_stalled_steps = 0
580
+ self._no_answer_step = -1 # last step where the best explicit response was N/A
581
+ # how many N/A alternations have we had so far? (for Inf loop detection)
582
+ self.n_no_answer_alternations = 0
500
583
  self.max_cost = max_cost
501
584
  self.max_tokens = max_tokens
502
585
  self.session_id = session_id
@@ -524,6 +607,7 @@ class Task:
524
607
  turns = self.turns if turns < 0 else turns
525
608
  i = 0
526
609
  while True:
610
+ self._step_idx = i # used in step() below
527
611
  self.step()
528
612
  done, status = self.done()
529
613
  if done:
@@ -537,7 +621,17 @@ class Task:
537
621
  else max(turns, settings.max_turns)
538
622
  )
539
623
  if max_turns > 0 and i >= max_turns:
540
- status = StatusCode.MAX_TURNS
624
+ # Important to distinguish between:
625
+ # (a) intentional run for a
626
+ # fixed number of turns, where we expect the pending message
627
+ # at that stage to be the desired result, and
628
+ # (b) hitting max_turns limit, which is not intentional, and is an
629
+ # exception, resulting in a None task result
630
+ status = (
631
+ StatusCode.MAX_TURNS
632
+ if i == settings.max_turns
633
+ else StatusCode.FIXED_TURNS
634
+ )
541
635
  break
542
636
  if (
543
637
  self.config.inf_loop_cycle_len > 0
@@ -553,9 +647,7 @@ class Task:
553
647
  """
554
648
  )
555
649
 
556
- final_result = self.result()
557
- if final_result is not None:
558
- final_result.metadata.status = status
650
+ final_result = self.result(status)
559
651
  self._post_run_loop()
560
652
  return final_result
561
653
 
@@ -597,8 +689,22 @@ class Task:
597
689
  # have come from another LLM), as far as this agent is concerned, the initial
598
690
  # message can be considered to be from the USER
599
691
  # (from the POV of this agent's LLM).
600
- self.task_progress = False
692
+
693
+ if (
694
+ self.restart
695
+ and caller is None
696
+ or self.config_sub_task.restart_as_subtask
697
+ and caller is not None
698
+ ):
699
+ # We are either at top level, with restart = True, OR
700
+ # we are a sub-task with restart_as_subtask = True,
701
+ # so reset own agent and recursively for all sub-tasks
702
+ self.reset_all_sub_tasks()
703
+
601
704
  self.n_stalled_steps = 0
705
+ self._no_answer_step = -1 # last step where the best explicit response was N/A
706
+ # how many N/A alternations have we had so far? (for Inf loop detection)
707
+ self.n_no_answer_alternations = 0
602
708
  self.max_cost = max_cost
603
709
  self.max_tokens = max_tokens
604
710
  self.session_id = session_id
@@ -622,6 +728,7 @@ class Task:
622
728
  turns = self.turns if turns < 0 else turns
623
729
  i = 0
624
730
  while True:
731
+ self._step_idx = i # used in step() below
625
732
  await self.step_async()
626
733
  await asyncio.sleep(0.01) # temp yield to avoid blocking
627
734
  done, status = self.done()
@@ -636,7 +743,17 @@ class Task:
636
743
  else max(turns, settings.max_turns)
637
744
  )
638
745
  if max_turns > 0 and i >= max_turns:
639
- status = StatusCode.MAX_TURNS
746
+ # Important to distinguish between:
747
+ # (a) intentional run for a
748
+ # fixed number of turns, where we expect the pending message
749
+ # at that stage to be the desired result, and
750
+ # (b) hitting max_turns limit, which is not intentional, and is an
751
+ # exception, resulting in a None task result
752
+ status = (
753
+ StatusCode.MAX_TURNS
754
+ if i == settings.max_turns
755
+ else StatusCode.FIXED_TURNS
756
+ )
640
757
  break
641
758
  if (
642
759
  self.config.inf_loop_cycle_len > 0
@@ -652,9 +769,7 @@ class Task:
652
769
  """
653
770
  )
654
771
 
655
- final_result = self.result()
656
- if final_result is not None:
657
- final_result.metadata.status = status
772
+ final_result = self.result(status)
658
773
  self._post_run_loop()
659
774
  return final_result
660
775
 
@@ -668,9 +783,6 @@ class Task:
668
783
  self.init(msg)
669
784
  # sets indentation to be printed prior to any output from agent
670
785
  self.agent.indent = self._indent
671
- if self.default_human_response is not None:
672
- self.agent.default_human_response = self.default_human_response
673
-
674
786
  self.message_history_idx = -1
675
787
  if isinstance(self.agent, ChatAgent):
676
788
  # mark where we are in the message history, so we can reset to this when
@@ -701,6 +813,23 @@ class Task:
701
813
  if self.erase_substeps:
702
814
  # TODO I don't like directly accessing agent message_history. Revisit.
703
815
  # (Pchalasani)
816
+ # Note: msg history will consist of:
817
+ # - H: the original msg history, ending at idx= self.message_history_idx
818
+ # - R: this agent's response, which presumably leads to:
819
+ # - X: a series of back-and-forth msgs (including with agent's own
820
+ # responders and with sub-tasks)
821
+ # - F: the final result message, from this agent.
822
+ # Here we are deleting all of [X] from the agent's message history,
823
+ # so that it simply looks as if the sub-tasks never happened.
824
+
825
+ dropped = self.agent.message_history[
826
+ self.message_history_idx + 2 : n_messages - 1
827
+ ]
828
+ # first delete the linked ChatDocuments (and descendants) from
829
+ # ObjectRegistry
830
+ for msg in dropped:
831
+ ChatDocument.delete_id(msg.chat_document_id)
832
+ # then delete the messages from the agent's message_history
704
833
  del self.agent.message_history[
705
834
  self.message_history_idx + 2 : n_messages - 1
706
835
  ]
@@ -727,7 +856,6 @@ class Task:
727
856
  `step_async()`. Consider refactoring to avoid duplication.
728
857
  """
729
858
  self.is_done = False
730
- self.step_progress = False
731
859
  parent = self.pending_message
732
860
  recipient = (
733
861
  ""
@@ -750,9 +878,11 @@ class Task:
750
878
 
751
879
  if (
752
880
  Entity.USER in self.responders
881
+ and self.interactive
753
882
  and not self.human_tried
754
883
  and not self.agent.has_tool_message_attempt(self.pending_message)
755
884
  ):
885
+ # When in interactive mode,
756
886
  # Give human first chance if they haven't been tried in last step,
757
887
  # and the msg is not a tool-call attempt;
758
888
  # This ensures human gets a chance to respond,
@@ -765,6 +895,8 @@ class Task:
765
895
  responders.insert(0, Entity.USER)
766
896
 
767
897
  found_response = False
898
+ # (responder, result) from a responder who explicitly said NO_ANSWER
899
+ no_answer_response: None | Tuple[Responder, ChatDocument] = None
768
900
  for r in responders:
769
901
  self.is_pass_thru = False
770
902
  if not self._can_respond(r):
@@ -778,10 +910,14 @@ class Task:
778
910
  recipient=recipient,
779
911
  ),
780
912
  )
913
+ # no need to register this dummy msg in ObjectRegistry
914
+ ChatDocument.delete_id(log_doc.id())
781
915
  self.log_message(r, log_doc)
782
916
  continue
783
917
  self.human_tried = r == Entity.USER
784
918
  result = self.response(r, turns)
919
+ if result and NO_ANSWER in result.content:
920
+ no_answer_response = (r, result)
785
921
  self.is_done = self._is_done_response(result, r)
786
922
  self.is_pass_thru = PASS in result.content if result else False
787
923
  if self.valid(result, r):
@@ -794,8 +930,15 @@ class Task:
794
930
  if self.is_done:
795
931
  # skip trying other responders in this step
796
932
  break
797
- if not found_response:
798
- self._process_invalid_step_result(parent)
933
+ if not found_response: # did not find a Non-NO_ANSWER response
934
+ if no_answer_response:
935
+ # even though there was no valid response from anyone in this step,
936
+ # if there was at least one who EXPLICITLY said NO_ANSWER, then
937
+ # we process that as a valid response.
938
+ r, result = no_answer_response
939
+ self._process_valid_responder_result(r, parent, result)
940
+ else:
941
+ self._process_invalid_step_result(parent)
799
942
  self._show_pending_message_if_debug()
800
943
  return self.pending_message
801
944
 
@@ -821,7 +964,6 @@ class Task:
821
964
  different context.
822
965
  """
823
966
  self.is_done = False
824
- self.step_progress = False
825
967
  parent = self.pending_message
826
968
  recipient = (
827
969
  ""
@@ -844,6 +986,7 @@ class Task:
844
986
 
845
987
  if (
846
988
  Entity.USER in self.responders
989
+ and self.interactive
847
990
  and not self.human_tried
848
991
  and not self.agent.has_tool_message_attempt(self.pending_message)
849
992
  ):
@@ -858,6 +1001,8 @@ class Task:
858
1001
  responders.insert(0, Entity.USER)
859
1002
 
860
1003
  found_response = False
1004
+ # (responder, result) from a responder who explicitly said NO_ANSWER
1005
+ no_answer_response: None | Tuple[Responder, ChatDocument] = None
861
1006
  for r in responders:
862
1007
  if not self._can_respond(r):
863
1008
  # create dummy msg for logging
@@ -870,10 +1015,14 @@ class Task:
870
1015
  recipient=recipient,
871
1016
  ),
872
1017
  )
1018
+ # no need to register this dummy msg in ObjectRegistry
1019
+ ChatDocument.delete_id(log_doc.id())
873
1020
  self.log_message(r, log_doc)
874
1021
  continue
875
1022
  self.human_tried = r == Entity.USER
876
1023
  result = await self.response_async(r, turns)
1024
+ if result and NO_ANSWER in result.content:
1025
+ no_answer_response = (r, result)
877
1026
  self.is_done = self._is_done_response(result, r)
878
1027
  self.is_pass_thru = PASS in result.content if result else False
879
1028
  if self.valid(result, r):
@@ -887,7 +1036,14 @@ class Task:
887
1036
  # skip trying other responders in this step
888
1037
  break
889
1038
  if not found_response:
890
- self._process_invalid_step_result(parent)
1039
+ if no_answer_response:
1040
+ # even though there was no valid response from anyone in this step,
1041
+ # if there was at least one who EXPLICITLY said NO_ANSWER, then
1042
+ # we process that as a valid response.
1043
+ r, result = no_answer_response
1044
+ self._process_valid_responder_result(r, parent, result)
1045
+ else:
1046
+ self._process_invalid_step_result(parent)
891
1047
  self._show_pending_message_if_debug()
892
1048
  return self.pending_message
893
1049
 
@@ -899,19 +1055,45 @@ class Task:
899
1055
  ) -> None:
900
1056
  """Processes valid result from a responder, during a step"""
901
1057
 
1058
+ # in case the valid response was a NO_ANSWER,
1059
+ if NO_ANSWER in result.content:
1060
+ if self._no_answer_step == self._step_idx - 2:
1061
+ # N/A two steps ago
1062
+ self.n_no_answer_alternations += 1
1063
+ else:
1064
+ # reset alternations counter
1065
+ self.n_no_answer_alternations = 0
1066
+
1067
+ # record the last step where the best explicit response was N/A
1068
+ self._no_answer_step = self._step_idx
1069
+
902
1070
  # pending_sender is of type Responder,
903
1071
  # i.e. it is either one of the agent's entities
904
1072
  # OR a sub-task, that has produced a valid response.
905
1073
  # Contrast this with self.pending_message.metadata.sender, which is an ENTITY
906
1074
  # of this agent, or a sub-task's agent.
907
1075
  if not self.is_pass_thru:
908
- self.pending_sender = r
909
- result.metadata.parent = parent
910
- if not self.is_pass_thru:
1076
+ if (
1077
+ self.pending_message is not None
1078
+ and self.pending_message.metadata.agent_id == self.agent.id
1079
+ ):
1080
+ # when pending msg is from our own agent, respect the sender set there,
1081
+ # since sometimes a response may "mock" as if the response is from
1082
+ # another entity (e.g when using RewindTool, the agent handler
1083
+ # returns a result as if it were from the LLM).
1084
+ self.pending_sender = result.metadata.sender
1085
+ else:
1086
+ # when pending msg is from a sub-task, the sender is the sub-task
1087
+ self.pending_sender = r
911
1088
  self.pending_message = result
1089
+ # set the parent/child links ONLY if not already set by agent internally,
1090
+ # which may happen when using the RewindTool
1091
+ if parent is not None and not result.metadata.parent_id:
1092
+ result.metadata.parent_id = parent.id()
1093
+ if parent is not None and not parent.metadata.child_id:
1094
+ parent.metadata.child_id = result.id()
1095
+
912
1096
  self.log_message(self.pending_sender, result, mark=True)
913
- self.step_progress = True
914
- self.task_progress = True
915
1097
  if self.is_pass_thru:
916
1098
  self.n_stalled_steps += 1
917
1099
  else:
@@ -933,17 +1115,20 @@ class Task:
933
1115
  parent (ChatDocument|None): parent message of the current message
934
1116
  """
935
1117
  self.n_stalled_steps += 1
936
- if (not self.task_progress or self.allow_null_result) and not self.is_pass_thru:
937
- # There has been no progress at all in this task, so we
938
- # update the pending_message to a dummy NO_ANSWER msg
1118
+ if self.allow_null_result and not self.is_pass_thru:
1119
+ # Null step-result is allowed, and we're not in a "pass-thru" situation,
1120
+ # so we update the pending_message to a dummy NO_ANSWER msg
939
1121
  # from the entity 'opposite' to the current pending_sender,
940
- # so we show "progress" and avoid getting stuck in an infinite loop.
1122
+ # so that the task can continue.
1123
+ # CAUTION: unless the LLM is instructed to signal DONE at an appropriate
1124
+ # time, this can result in an infinite loop.
941
1125
  responder = (
942
1126
  Entity.LLM if self.pending_sender == Entity.USER else Entity.USER
943
1127
  )
1128
+ parent_id = "" if parent is None else parent.id()
944
1129
  self.pending_message = ChatDocument(
945
1130
  content=NO_ANSWER,
946
- metadata=ChatDocMetaData(sender=responder, parent=parent),
1131
+ metadata=ChatDocMetaData(sender=responder, parent_id=parent_id),
947
1132
  )
948
1133
  self.pending_sender = responder
949
1134
  self.log_message(self.pending_sender, self.pending_message, mark=True)
@@ -975,7 +1160,9 @@ class Task:
975
1160
  max_cost=self.max_cost,
976
1161
  max_tokens=self.max_tokens,
977
1162
  )
978
- result_str = str(ChatDocument.to_LLMMessage(result))
1163
+ result_str = ( # only used by callback to display content and possible tool
1164
+ "NONE" if result is None else str(ChatDocument.to_LLMMessage(result))
1165
+ )
979
1166
  maybe_tool = len(extract_top_level_json(result_str)) > 0
980
1167
  self.callbacks.show_subtask_response(
981
1168
  task=e,
@@ -1063,16 +1250,23 @@ class Task:
1063
1250
  result = await response_fn(self.pending_message)
1064
1251
  return self._process_result_routing(result)
1065
1252
 
1066
- def result(self) -> ChatDocument:
1253
+ def result(self, status: StatusCode | None = None) -> ChatDocument | None:
1067
1254
  """
1068
1255
  Get result of task. This is the default behavior.
1069
1256
  Derived classes can override this.
1070
1257
 
1071
1258
  Note the result of a task is returned as if it is from the User entity.
1072
1259
 
1260
+ Args:
1261
+ status (StatusCode): status of the task when it ended
1073
1262
  Returns:
1074
1263
  ChatDocument: result of task
1075
1264
  """
1265
+ if status in [StatusCode.STALLED, StatusCode.MAX_TURNS, StatusCode.INF_LOOP]:
1266
+ # In these case we don't know (and don't want to try to guess)
1267
+ # what the task result should be, so we return None
1268
+ return None
1269
+
1076
1270
  result_msg = self.pending_message
1077
1271
 
1078
1272
  content = result_msg.content if result_msg else ""
@@ -1084,12 +1278,11 @@ class Task:
1084
1278
  block = result_msg.metadata.block if result_msg else None
1085
1279
  recipient = result_msg.metadata.recipient if result_msg else ""
1086
1280
  tool_ids = result_msg.metadata.tool_ids if result_msg else []
1087
- status = result_msg.metadata.status if result_msg else None
1088
1281
 
1089
1282
  # regardless of which entity actually produced the result,
1090
1283
  # when we return the result, we set entity to USER
1091
1284
  # since to the "parent" task, this result is equivalent to a response from USER
1092
- return ChatDocument(
1285
+ result_doc = ChatDocument(
1093
1286
  content=content,
1094
1287
  function_call=fun_call,
1095
1288
  tool_messages=tool_messages,
@@ -1097,12 +1290,18 @@ class Task:
1097
1290
  source=Entity.USER,
1098
1291
  sender=Entity.USER,
1099
1292
  block=block,
1100
- status=status,
1293
+ status=status or (result_msg.metadata.status if result_msg else None),
1101
1294
  sender_name=self.name,
1102
1295
  recipient=recipient,
1103
1296
  tool_ids=tool_ids,
1297
+ parent_id=result_msg.id() if result_msg else "",
1298
+ agent_id=str(self.agent.id),
1104
1299
  ),
1105
1300
  )
1301
+ if self.pending_message is not None:
1302
+ self.pending_message.metadata.child_id = result_doc.id()
1303
+
1304
+ return result_doc
1106
1305
 
1107
1306
  def _is_empty_message(self, msg: str | ChatDocument | None) -> bool:
1108
1307
  """
@@ -1171,6 +1370,9 @@ class Task:
1171
1370
  If the set of last (W * m) messages are the same as the
1172
1371
  set of m dominant messages, then we are likely in a loop.
1173
1372
  """
1373
+ if self.n_no_answer_alternations > self.config.inf_loop_wait_factor:
1374
+ return True
1375
+
1174
1376
  max_cycle_len = self.config.inf_loop_cycle_len
1175
1377
  if max_cycle_len <= 0:
1176
1378
  # no loop detection
@@ -1239,8 +1441,8 @@ class Task:
1239
1441
  and result.content in USER_QUIT_STRINGS
1240
1442
  and result.metadata.sender == Entity.USER
1241
1443
  )
1242
- if self._level == 0 and self.only_user_quits_root:
1243
- # for top-level task, only user can quit out
1444
+ if self._level == 0 and self.interactive and self.only_user_quits_root:
1445
+ # for top-level task, in interactive mode, only user can quit out
1244
1446
  return (user_quit, StatusCode.USER_QUIT if user_quit else StatusCode.OK)
1245
1447
 
1246
1448
  if self.is_done:
@@ -1282,11 +1484,6 @@ class Task:
1282
1484
  and self.caller.name != ""
1283
1485
  and result.metadata.recipient == self.caller.name
1284
1486
  )
1285
- # or (
1286
- # # Task controller is "stuck", has nothing to say
1287
- # NO_ANSWER in result.content
1288
- # and result.metadata.sender == self.controller
1289
- # )
1290
1487
  or user_quit
1291
1488
  )
1292
1489
  return (final, StatusCode.OK)
@@ -1468,7 +1665,6 @@ def parse_routing(
1468
1665
  return True, addressee, None
1469
1666
  else:
1470
1667
  return False, addressee, content_to_send
1471
- AT = "@"
1472
1668
  if (
1473
1669
  AT in content
1474
1670
  and (addressee_content := parse_addressed_message(content, AT))[0] is not None