langroid 0.2.12__py3-none-any.whl → 0.3.1__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/team.py ADDED
@@ -0,0 +1,1758 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import copy
5
+ import logging
6
+ import re
7
+ import threading
8
+ from collections import Counter, deque
9
+ from pathlib import Path
10
+ from types import SimpleNamespace
11
+ from typing import (
12
+ Any,
13
+ Callable,
14
+ Coroutine,
15
+ Deque,
16
+ Dict,
17
+ List,
18
+ Optional,
19
+ Tuple,
20
+ Type,
21
+ cast,
22
+ )
23
+
24
+ import numpy as np
25
+ from rich import print
26
+ from rich.markup import escape
27
+
28
+ from langroid.agent.base import Agent
29
+ from langroid.agent.chat_agent import ChatAgent, ChatAgentConfig
30
+ from langroid.agent.chat_document import (
31
+ ChatDocLoggerFields,
32
+ ChatDocMetaData,
33
+ ChatDocument,
34
+ StatusCode,
35
+ )
36
+ from langroid.agent.scheduler.base import Scheduler
37
+ from langroid.agent.scheduler.fixed_sweep_scheduler import FixedSweepScheduler
38
+ from langroid.cachedb.redis_cachedb import RedisCache, RedisCacheConfig
39
+ from langroid.exceptions import InfiniteLoopException
40
+ from langroid.mytypes import Entity
41
+ from langroid.parsing.parse_json import extract_top_level_json
42
+ from langroid.parsing.routing import parse_addressed_message
43
+ from langroid.pydantic_v1 import BaseModel
44
+ from langroid.utils.configuration import settings
45
+ from langroid.utils.constants import (
46
+ DONE,
47
+ NO_ANSWER,
48
+ PASS,
49
+ PASS_TO,
50
+ SEND_TO,
51
+ USER_QUIT_STRINGS,
52
+ )
53
+ from langroid.utils.logging import RichFileLogger, setup_file_logger
54
+ from langroid.utils.object_registry import scheduled_cleanup
55
+ from langroid.utils.system import hash
56
+
57
+ logger = logging.getLogger(__name__)
58
+
59
+ TaskType = Type["Task"]
60
+ TaskConfigType = Type["TaskConfig"]
61
+ Responder = Entity | TaskType
62
+
63
+
64
+ TaskOrList = (
65
+ TaskType
66
+ | List[TaskType]
67
+ | Tuple[TaskType, TaskConfigType]
68
+ | List[Tuple[TaskType, TaskConfigType]]
69
+ )
70
+
71
+
72
+ def noop_fn(*args: List[Any], **kwargs: Dict[str, Any]) -> None:
73
+ pass
74
+
75
+
76
+ class TaskConfig(BaseModel):
77
+ """Configuration for a Task. This is a container for any params that
78
+ we didn't include in the task `__init__` method.
79
+ We may eventually move all the task __init__ params to this class, analogous to how
80
+ we have config classes for `Agent`, `ChatAgent`, `LanguageModel`, etc.
81
+
82
+ Attributes:
83
+ inf_loop_cycle_len (int): max exact-loop cycle length: 0 => no inf loop test
84
+ inf_loop_dominance_factor (float): dominance factor for exact-loop detection
85
+ inf_loop_wait_factor (int): wait this * cycle_len msgs before loop-check
86
+ restart_subtask_run (bool): whether to restart *every* run of this task
87
+ when run as a subtask.
88
+ addressing_prefix (str): "@"-like prefix an agent can use to address other
89
+ agents, or entities of the agent. E.g., if this is "@", the addressing
90
+ string would be "@Alice", or "@user", "@llm", "@agent", etc.
91
+ If this is an empty string, then addressing is disabled.
92
+ Default is empty string "".
93
+ CAUTION: this is a deprecated practice, since normal prompts
94
+ can accidentally contain such addressing prefixes, and will break
95
+ your runs. This could happen especially when your prompt/context
96
+ contains code, but of course could occur in normal text as well.
97
+ Instead, use the `RecipientTool` to have agents address other agents or
98
+ entities. If you do choose to use `addressing_prefix`, the recommended
99
+ setting is to use `langroid.utils.constants.AT`, which currently is "|@|".
100
+ Note that this setting does NOT affect the use of `constants.SEND_TO` --
101
+ this is always enabled since this is a critical way for responders to
102
+ indicate that the message should be sent to a specific entity/agent.
103
+ (Search for "SEND_TO" in the examples/ dir to see how this is used.)
104
+ """
105
+
106
+ inf_loop_cycle_len: int = 10
107
+ inf_loop_dominance_factor: float = 1.5
108
+ inf_loop_wait_factor: int = 5
109
+ restart_as_subtask: bool = False
110
+ logs_dir: str = "logs"
111
+ addressing_prefix: str = ""
112
+ scheduler: Type[Scheduler] = FixedSweepScheduler
113
+
114
+
115
+ class Task:
116
+ """
117
+ A `Task` wraps an `Agent` object, and sets up the `Agent`'s goals and instructions.
118
+ A `Task` maintains two key variables:
119
+
120
+ - `self.pending_message`, which is the message awaiting a response, and
121
+ - `self.pending_sender`, which is the entity that sent the pending message.
122
+
123
+ The possible responders to `self.pending_message` are the `Agent`'s own "native"
124
+ responders (`agent_response`, `llm_response`, and `user_response`), and
125
+ the `run()` methods of any sub-tasks. All responders have the same type-signature
126
+ (somewhat simplified):
127
+ ```
128
+ str | ChatDocument -> ChatDocument
129
+ ```
130
+ Responders may or may not specify an intended recipient of their generated response.
131
+
132
+ The main top-level method in the `Task` class is `run()`, which repeatedly calls
133
+ `step()` until `done()` returns true. The `step()` represents a "turn" in the
134
+ conversation: this method sequentially (in round-robin fashion) calls the responders
135
+ until it finds one that generates a *valid* response to the `pending_message`
136
+ (as determined by the `valid()` method). Once a valid response is found,
137
+ `step()` updates the `pending_message` and `pending_sender` variables,
138
+ and on the next iteration, `step()` re-starts its search for a valid response
139
+ *from the beginning* of the list of responders (the exception being that the
140
+ human user always gets a chance to respond after each non-human valid response).
141
+ This process repeats until `done()` returns true, at which point `run()` returns
142
+ the value of `result()`, which is the final result of the task.
143
+ """
144
+
145
+ # class variable called `cache` that is a RedisCache object
146
+ _cache: RedisCache | None = None
147
+ _background_tasks_started: bool = False
148
+
149
+ def __init__(
150
+ self,
151
+ agent: Optional[Agent] = ChatAgent(),
152
+ name: str = "",
153
+ llm_delegate: bool = False,
154
+ single_round: bool = False,
155
+ system_message: str = "",
156
+ user_message: str | None = "",
157
+ restart: bool = True,
158
+ default_human_response: Optional[str] = None,
159
+ interactive: bool = True,
160
+ only_user_quits_root: bool = True,
161
+ erase_substeps: bool = False,
162
+ allow_null_result: bool = False,
163
+ max_stalled_steps: int = 5,
164
+ done_if_no_response: List[Responder] = [],
165
+ done_if_response: List[Responder] = [],
166
+ config: TaskConfig = TaskConfig(),
167
+ sub_task: None | TaskOrList = None,
168
+ **kwargs: Any, # catch-all for any legacy params, for backwards compatibility
169
+ ):
170
+ """
171
+ A task to be performed by an agent.
172
+
173
+ Args:
174
+ agent (Agent): agent associated with the task. Defaults to a basic ChatAgent
175
+ equipped with an LLM. If this is None, then an "empty" ChatAgent
176
+ with no LLM (and no responders) is created. In this case the task will
177
+ not have any "own" responders, and the only responders will be the
178
+ run() methods of the sub-tasks.
179
+ name (str): name of the task
180
+ llm_delegate (bool):
181
+ Whether to delegate "control" to LLM; conceptually,
182
+ the "controlling entity" is the one "seeking" responses to its queries,
183
+ and has a goal it is aiming to achieve, and decides when a task is done.
184
+ The "controlling entity" is either the LLM or the USER.
185
+ (Note within a Task there is just one
186
+ LLM, and all other entities are proxies of the "User" entity).
187
+ See also: `done_if_response`, `done_if_no_response` for more granular
188
+ control of task termination.
189
+ single_round (bool):
190
+ If true, task runs until one message by "controller"
191
+ (i.e. LLM if `llm_delegate` is true, otherwise USER)
192
+ and subsequent response by non-controller [When a tool is involved,
193
+ this will not give intended results. See `done_if_response`,
194
+ `done_if_no_response` below].
195
+ termination]. If false, runs for the specified number of turns in
196
+ `run`, or until `done()` is true.
197
+ One run of step() is considered a "turn".
198
+ See also: `done_if_response`, `done_if_no_response` for more granular
199
+ control of task termination.
200
+ system_message (str): if not empty, overrides agent's system_message
201
+ user_message (str): if not empty, overrides agent's user_message
202
+ restart (bool): if true, resets the agent's message history *at every run*.
203
+ default_human_response (str|None): default response from user; useful for
204
+ testing, to avoid interactive input from user.
205
+ [Instead of this, setting `interactive` usually suffices]
206
+ interactive (bool): if true, wait for human input after each non-human
207
+ response (prevents infinite loop of non-human responses).
208
+ Default is true. If false, then `default_human_response` is set to ""
209
+ Note: When interactive = False, the one exception is when the user
210
+ is explicitly addressed, via "@user" or using RecipientTool, in which
211
+ case the system will wait for a user response. In other words, use
212
+ `interactive=False` when you want a "largely non-interactive"
213
+ run, with the exception of explicit user addressing.
214
+ only_user_quits_root (bool): if true, when interactive=True, only user can
215
+ quit the root task (Ignored when interactive=False).
216
+ erase_substeps (bool): if true, when task completes, erase intermediate
217
+ conversation with subtasks from this agent's `message_history`, and also
218
+ erase all subtask agents' `message_history`.
219
+ Note: erasing can reduce prompt sizes, but results in repetitive
220
+ sub-task delegation.
221
+ allow_null_result (bool):
222
+ If true, create dummy NO_ANSWER response when no valid response is found
223
+ in a step.
224
+ Optional, default is False.
225
+ *Note:* In non-interactive mode, when this is set to True,
226
+ you can have a situation where an LLM generates (non-tool) text,
227
+ and no other responders have valid responses, and a "Null result"
228
+ is inserted as a dummy response from the User entity, so the LLM
229
+ will now respond to this Null result, and this will continue
230
+ until the LLM emits a DONE signal (if instructed to do so),
231
+ otherwise langroid detects a potential infinite loop after
232
+ a certain number of such steps (= `TaskConfig.inf_loop_wait_factor`)
233
+ and will raise an InfiniteLoopException.
234
+ max_stalled_steps (int): task considered done after this many consecutive
235
+ steps with no progress. Default is 3.
236
+ done_if_no_response (List[Responder]): consider task done if NULL
237
+ response from any of these responders. Default is empty list.
238
+ done_if_response (List[Responder]): consider task done if NON-NULL
239
+ response from any of these responders. Default is empty list.
240
+ config (TaskConfig): configuration for the task.
241
+ sub_task (List[Task]): list of sub-tasks that this task can delegate to.
242
+ """
243
+ if agent is None:
244
+ # dummy agent with no LLM, and hence no responders at all.
245
+ agent = ChatAgent(ChatAgentConfig(llm=None))
246
+ self.scheduler_class = config.scheduler
247
+ self.scheduler: Scheduler | None = None # to be set in run()
248
+ self.scheduler_async: Scheduler | None = None # to be set in run_async()
249
+ self.callbacks = SimpleNamespace(
250
+ show_subtask_response=noop_fn,
251
+ set_parent_agent=noop_fn,
252
+ )
253
+ self.config = config
254
+ # how to behave as a sub-task; can be overridden by `add_sub_task()`
255
+ self.config_sub_task = copy.deepcopy(config)
256
+ # counts of distinct pending messages in history,
257
+ # to help detect (exact) infinite loops
258
+ self.message_counter: Counter[str] = Counter()
259
+ self._init_message_counter()
260
+
261
+ self.history: Deque[str] = deque(
262
+ maxlen=self.config.inf_loop_cycle_len * self.config.inf_loop_wait_factor
263
+ )
264
+ # copy the agent's config, so that we don't modify the original agent's config,
265
+ # which may be shared by other agents.
266
+ try:
267
+ config_copy = copy.deepcopy(agent.config)
268
+ agent.config = config_copy
269
+ except Exception:
270
+ logger.warning(
271
+ """
272
+ Failed to deep-copy Agent config during task creation,
273
+ proceeding with original config. Be aware that changes to
274
+ the config may affect other agents using the same config.
275
+ """
276
+ )
277
+ self.restart = restart
278
+ agent = cast(ChatAgent, agent)
279
+ self.agent: ChatAgent = agent
280
+ if isinstance(agent, ChatAgent) and len(agent.message_history) == 0 or restart:
281
+ self.agent.clear_history(0)
282
+ self.agent.clear_dialog()
283
+ # possibly change the system and user messages
284
+ if system_message:
285
+ # we always have at least 1 task_message
286
+ self.agent.set_system_message(system_message)
287
+ if user_message:
288
+ self.agent.set_user_message(user_message)
289
+ self.max_cost: float = 0
290
+ self.max_tokens: int = 0
291
+ self.session_id: str = ""
292
+ self.logger: None | RichFileLogger = None
293
+ self.tsv_logger: None | logging.Logger = None
294
+ self.color_log: bool = False if settings.notebook else True
295
+
296
+ self.n_stalled_steps = 0 # how many consecutive steps with no progress?
297
+ # how many 2-step-apart alternations of no_answer step-result have we had,
298
+ # i.e. x1, N/A, x2, N/A, x3, N/A ...
299
+ self.n_no_answer_alternations = 0
300
+ self._no_answer_step: int = -5
301
+ self._step_idx = -1 # current step index
302
+ self.max_stalled_steps = max_stalled_steps
303
+ self.done_if_response = [r.value for r in done_if_response]
304
+ self.done_if_no_response = [r.value for r in done_if_no_response]
305
+ self.is_done = False # is task done (based on response)?
306
+ self.is_pass_thru = False # is current response a pass-thru?
307
+ if name:
308
+ # task name overrides name in agent config
309
+ agent.config.name = name
310
+ self.name = name or agent.config.name
311
+ self.value: str = self.name
312
+
313
+ self.default_human_response = default_human_response
314
+ if default_human_response is not None:
315
+ # only override agent's default_human_response if it is explicitly set
316
+ self.agent.default_human_response = default_human_response
317
+ self.interactive = interactive
318
+ self.agent.interactive = interactive
319
+ self.only_user_quits_root = only_user_quits_root
320
+ self.message_history_idx = -1
321
+
322
+ # set to True if we want to collapse multi-turn conversation with sub-tasks into
323
+ # just the first outgoing message and last incoming message.
324
+ # Note this also completely erases sub-task agents' message_history.
325
+ self.erase_substeps = erase_substeps
326
+ self.allow_null_result = allow_null_result
327
+
328
+ agent_entity_responders = agent.entity_responders()
329
+ agent_entity_responders_async = agent.entity_responders_async()
330
+ self.responders: List[Responder] = [e for e, _ in agent_entity_responders]
331
+ self.responders_async: List[Responder] = [
332
+ e for e, _ in agent_entity_responders_async
333
+ ]
334
+ self.non_human_responders: List[Responder] = [
335
+ r for r in self.responders if r != Entity.USER
336
+ ]
337
+ self.non_human_responders_async: List[Responder] = [
338
+ r for r in self.responders_async if r != Entity.USER
339
+ ]
340
+
341
+ self.human_tried = False # did human get a chance to respond in last step?
342
+ self._entity_responder_map: Dict[
343
+ Entity, Callable[..., Optional[ChatDocument]]
344
+ ] = dict(agent_entity_responders)
345
+
346
+ self._entity_responder_async_map: Dict[
347
+ Entity, Callable[..., Coroutine[Any, Any, Optional[ChatDocument]]]
348
+ ] = dict(agent_entity_responders_async)
349
+
350
+ self.name_sub_task_map: Dict[str, Task] = {}
351
+ # latest message in a conversation among entities and agents.
352
+ self.pending_message: Optional[ChatDocument] = None
353
+ self.pending_sender: Responder = Entity.USER
354
+ self.single_round = single_round
355
+ self.turns = -1 # no limit
356
+ self.llm_delegate = llm_delegate
357
+ if llm_delegate:
358
+ if self.single_round:
359
+ # 0: User instructs (delegating to LLM);
360
+ # 1: LLM (as the Controller) asks;
361
+ # 2: user replies.
362
+ self.turns = 2
363
+ else:
364
+ if self.single_round:
365
+ # 0: User (as Controller) asks,
366
+ # 1: LLM replies.
367
+ self.turns = 1
368
+ # other sub_tasks this task can delegate to
369
+ self.sub_tasks: List[Task] = []
370
+ self.caller: Task | None = None # which task called this task's `run` method
371
+ if sub_task is not None:
372
+ self.add_sub_task(sub_task)
373
+
374
+ def clone(self, i: int) -> "Task":
375
+ """
376
+ Returns a copy of this task, with a new agent.
377
+ """
378
+ assert isinstance(self.agent, ChatAgent), "Task clone only works for ChatAgent"
379
+ agent: ChatAgent = self.agent.clone(i)
380
+ return Task(
381
+ agent,
382
+ name=self.name + f"-{i}",
383
+ llm_delegate=self.llm_delegate,
384
+ single_round=self.single_round,
385
+ system_message=self.agent.system_message,
386
+ user_message=self.agent.user_message,
387
+ restart=self.restart,
388
+ default_human_response=self.default_human_response,
389
+ interactive=self.interactive,
390
+ erase_substeps=self.erase_substeps,
391
+ allow_null_result=self.allow_null_result,
392
+ max_stalled_steps=self.max_stalled_steps,
393
+ done_if_no_response=[Entity(s) for s in self.done_if_no_response],
394
+ done_if_response=[Entity(s) for s in self.done_if_response],
395
+ config=self.config,
396
+ )
397
+
398
+ @classmethod
399
+ def cache(cls) -> RedisCache:
400
+ if cls._cache is None:
401
+ cls._cache = RedisCache(RedisCacheConfig(fake=False))
402
+ return cls._cache
403
+
404
+ @classmethod
405
+ def _start_background_tasks(cls) -> None:
406
+ """Start background object registry cleanup thread. NOT USED."""
407
+ if cls._background_tasks_started:
408
+ return
409
+ cls._background_tasks_started = True
410
+ cleanup_thread = threading.Thread(
411
+ target=scheduled_cleanup,
412
+ args=(600,),
413
+ daemon=True,
414
+ )
415
+ cleanup_thread.start()
416
+
417
+ def __repr__(self) -> str:
418
+ return f"{self.name}"
419
+
420
+ def __str__(self) -> str:
421
+ return f"{self.name}"
422
+
423
+ def _init_message_counter(self) -> None:
424
+ self.message_counter.clear()
425
+ # create a unique string that will not likely be in any message,
426
+ # so we always have a message with count=1
427
+ self.message_counter.update([hash("___NO_MESSAGE___")])
428
+
429
+ def _cache_session_store(self, key: str, value: str) -> None:
430
+ """
431
+ Cache a key-value pair for the current session.
432
+ E.g. key = "kill", value = "1"
433
+ """
434
+ try:
435
+ self.cache().store(f"{self.session_id}:{key}", value)
436
+ except Exception as e:
437
+ logging.error(f"Error in Task._cache_session_store: {e}")
438
+
439
+ def _cache_session_lookup(self, key: str) -> Dict[str, Any] | str | None:
440
+ """
441
+ Retrieve a value from the cache for the current session.
442
+ """
443
+ session_id_key = f"{self.session_id}:{key}"
444
+ try:
445
+ cached_val = self.cache().retrieve(session_id_key)
446
+ except Exception as e:
447
+ logging.error(f"Error in Task._cache_session_lookup: {e}")
448
+ return None
449
+ return cached_val
450
+
451
+ def _is_kill(self) -> bool:
452
+ """
453
+ Check if the current session is killed.
454
+ """
455
+ return self._cache_session_lookup("kill") == "1"
456
+
457
+ def _set_alive(self) -> None:
458
+ """
459
+ Initialize the kill status of the current session.
460
+ """
461
+ self._cache_session_store("kill", "0")
462
+
463
+ @classmethod
464
+ def kill_session(cls, session_id: str = "") -> None:
465
+ """
466
+ Kill the session with the given session_id.
467
+ """
468
+ session_id_kill_key = f"{session_id}:kill"
469
+ cls.cache().store(session_id_kill_key, "1")
470
+
471
+ def kill(self) -> None:
472
+ """
473
+ Kill the task run associated with the current session.
474
+ """
475
+ self._cache_session_store("kill", "1")
476
+
477
+ @property
478
+ def _level(self) -> int:
479
+ if self.caller is None:
480
+ return 0
481
+ return self.caller._level + 1
482
+
483
+ @property
484
+ def _indent(self) -> str:
485
+ return "...|" * self._level
486
+
487
+ @property
488
+ def _enter(self) -> str:
489
+ return self._indent + ">>>"
490
+
491
+ @property
492
+ def _leave(self) -> str:
493
+ return self._indent + "<<<"
494
+
495
+ def add_sub_task(
496
+ self,
497
+ task: TaskOrList,
498
+ ) -> None:
499
+ """
500
+ Add a sub-task (or list of subtasks) that this task can delegate
501
+ (or fail-over) to. Note that the sequence of sub-tasks is important,
502
+ since these are tried in order, as the parent task searches for a valid
503
+ response (unless a sub-task is explicitly addressed).
504
+
505
+ Args:
506
+ task: A task, or list of tasks, or a tuple of task and task config,
507
+ or a list of tuples of task and task config.
508
+ These tasks are added as sub-tasks of the current task.
509
+ The task configs (if any) dictate how the tasks are run when
510
+ invoked as sub-tasks of other tasks. This allows users to specify
511
+ behavior applicable only in the context of a particular task-subtask
512
+ combination.
513
+ """
514
+ if isinstance(task, list):
515
+ for t in task:
516
+ self.add_sub_task(t)
517
+ return
518
+
519
+ if isinstance(task, tuple):
520
+ task, config = task
521
+ else:
522
+ config = TaskConfig()
523
+ task.config_sub_task = config
524
+ self.sub_tasks.append(task)
525
+ self.name_sub_task_map[task.name] = task
526
+ self.responders.append(cast(Responder, task))
527
+ self.responders_async.append(cast(Responder, task))
528
+ self.non_human_responders.append(cast(Responder, task))
529
+ self.non_human_responders_async.append(cast(Responder, task))
530
+
531
+ def init(self, msg: None | str | ChatDocument = None) -> ChatDocument | None:
532
+ """
533
+ Initialize the task, with an optional message to start the conversation.
534
+ Initializes `self.pending_message` and `self.pending_sender`.
535
+ Args:
536
+ msg (str|ChatDocument): optional message to start the conversation.
537
+
538
+ Returns:
539
+ (ChatDocument|None): the initialized `self.pending_message`.
540
+ Currently not used in the code, but provided for convenience.
541
+ """
542
+ self.pending_sender = Entity.USER
543
+ if isinstance(msg, str):
544
+ self.pending_message = ChatDocument(
545
+ content=msg,
546
+ metadata=ChatDocMetaData(
547
+ sender=Entity.USER,
548
+ ),
549
+ )
550
+ elif msg is None and len(self.agent.message_history) > 1:
551
+ # if agent has a history beyond system msg, set the
552
+ # pending message to the ChatDocument linked from
553
+ # last message in the history
554
+ last_agent_msg = self.agent.message_history[-1]
555
+ self.pending_message = ChatDocument.from_id(last_agent_msg.chat_document_id)
556
+ if self.pending_message is not None:
557
+ self.pending_sender = self.pending_message.metadata.sender
558
+ else:
559
+ if isinstance(msg, ChatDocument):
560
+ # carefully deep-copy: fresh metadata.id, register
561
+ # as new obj in registry
562
+ self.pending_message = ChatDocument.deepcopy(msg)
563
+ if self.pending_message is not None and self.caller is not None:
564
+ # msg may have come from `caller`, so we pretend this is from
565
+ # the CURRENT task's USER entity
566
+ self.pending_message.metadata.sender = Entity.USER
567
+ # update parent, child, agent pointers
568
+ if msg is not None:
569
+ msg.metadata.child_id = self.pending_message.metadata.id
570
+ self.pending_message.metadata.parent_id = msg.metadata.id
571
+ self.pending_message.metadata.agent_id = self.agent.id
572
+
573
+ self._show_pending_message_if_debug()
574
+
575
+ if self.caller is not None and self.caller.logger is not None:
576
+ self.logger = self.caller.logger
577
+ else:
578
+ self.logger = RichFileLogger(
579
+ str(Path(self.config.logs_dir) / f"{self.name}.log"),
580
+ color=self.color_log,
581
+ )
582
+
583
+ if self.caller is not None and self.caller.tsv_logger is not None:
584
+ self.tsv_logger = self.caller.tsv_logger
585
+ else:
586
+ self.tsv_logger = setup_file_logger(
587
+ "tsv_logger",
588
+ str(Path(self.config.logs_dir) / f"{self.name}.tsv"),
589
+ )
590
+ header = ChatDocLoggerFields().tsv_header()
591
+ self.tsv_logger.info(f" \tTask\tResponder\t{header}")
592
+
593
+ self.log_message(Entity.USER, self.pending_message)
594
+ return self.pending_message
595
+
596
+ def reset_all_sub_tasks(self) -> None:
597
+ """Recursively reset message history of own agent and all sub-tasks"""
598
+ self.agent.clear_history(0)
599
+ self.agent.clear_dialog()
600
+ for t in self.sub_tasks:
601
+ t.reset_all_sub_tasks()
602
+
603
+ def run(
604
+ self,
605
+ msg: Optional[str | ChatDocument] = None,
606
+ turns: int = -1,
607
+ caller: None | Task = None,
608
+ max_cost: float = 0,
609
+ max_tokens: int = 0,
610
+ session_id: str = "",
611
+ ) -> Optional[ChatDocument]:
612
+ """Synchronous version of `run_async()`.
613
+ See `run_async()` for details."""
614
+ if (self.restart and caller is None) or (
615
+ self.config_sub_task.restart_as_subtask and caller is not None
616
+ ):
617
+ # We are either at top level, with restart = True, OR
618
+ # we are a sub-task with restart_as_subtask = True,
619
+ # so reset own agent and recursively for all sub-tasks
620
+ self.reset_all_sub_tasks()
621
+
622
+ self.n_stalled_steps = 0
623
+ self._no_answer_step = -5 # last step where the best explicit response was N/A
624
+ # how many N/A alternations have we had so far? (for Inf loop detection)
625
+ self.n_no_answer_alternations = 0
626
+ self.max_cost = max_cost
627
+ self.max_tokens = max_tokens
628
+ self.session_id = session_id
629
+ self._set_alive()
630
+ self._init_message_counter()
631
+ self.history.clear()
632
+
633
+ assert (
634
+ msg is None or isinstance(msg, str) or isinstance(msg, ChatDocument)
635
+ ), f"msg arg in Task.run() must be None, str, or ChatDocument, not {type(msg)}"
636
+
637
+ if (
638
+ isinstance(msg, ChatDocument)
639
+ and msg.metadata.recipient != ""
640
+ and msg.metadata.recipient != self.name
641
+ ):
642
+ # this task is not the intended recipient so return None
643
+ return None
644
+ self._pre_run_loop(
645
+ msg=msg,
646
+ caller=caller,
647
+ is_async=False,
648
+ )
649
+ self.scheduler = self.scheduler_class(
650
+ self.non_human_responders.copy(),
651
+ heads=len(
652
+ [r for r in self.agent.entity_responders() if r[0] != Entity.USER]
653
+ ),
654
+ )
655
+ # self.turns overrides if it is > 0 and turns not set (i.e. = -1)
656
+ turns = self.turns if turns < 0 else turns
657
+ i = 0
658
+ while True:
659
+ self._step_idx = i # used in step() below
660
+ self.step()
661
+ done, status = self.done()
662
+ if done:
663
+ if self._level == 0 and not settings.quiet:
664
+ print("[magenta]Bye, hope this was useful!")
665
+ break
666
+ i += 1
667
+ max_turns = (
668
+ min(turns, settings.max_turns)
669
+ if turns > 0 and settings.max_turns > 0
670
+ else max(turns, settings.max_turns)
671
+ )
672
+ if max_turns > 0 and i >= max_turns:
673
+ # Important to distinguish between:
674
+ # (a) intentional run for a
675
+ # fixed number of turns, where we expect the pending message
676
+ # at that stage to be the desired result, and
677
+ # (b) hitting max_turns limit, which is not intentional, and is an
678
+ # exception, resulting in a None task result
679
+ status = (
680
+ StatusCode.MAX_TURNS
681
+ if i == settings.max_turns
682
+ else StatusCode.FIXED_TURNS
683
+ )
684
+ break
685
+ if (
686
+ self.config.inf_loop_cycle_len > 0
687
+ and i % self.config.inf_loop_cycle_len == 0
688
+ and self._maybe_infinite_loop()
689
+ or self.n_no_answer_alternations > self.config.inf_loop_wait_factor
690
+ ):
691
+ raise InfiniteLoopException(
692
+ """Possible infinite loop detected!
693
+ You can adjust infinite loop detection (or turn it off)
694
+ by changing the params in the TaskConfig passed to the Task
695
+ constructor; see here:
696
+ https://langroid.github.io/langroid/reference/agent/task/#langroid.agent.task.TaskConfig
697
+ """
698
+ )
699
+
700
+ final_result = self.result(status)
701
+ self._post_run_loop()
702
+ return final_result
703
+
704
+ async def run_async(
705
+ self,
706
+ msg: Optional[str | ChatDocument] = None,
707
+ turns: int = -1,
708
+ caller: None | Task = None,
709
+ max_cost: float = 0,
710
+ max_tokens: int = 0,
711
+ session_id: str = "",
712
+ ) -> Optional[ChatDocument]:
713
+ """
714
+ Loop over `step()` until task is considered done or `turns` is reached.
715
+ Runs asynchronously.
716
+
717
+ Args:
718
+ msg (str|ChatDocument): initial *user-role* message to process; if None,
719
+ the LLM will respond to its initial `self.task_messages`
720
+ which set up and kick off the overall task.
721
+ The agent tries to achieve this goal by looping
722
+ over `self.step()` until the task is considered
723
+ done; this can involve a series of messages produced by Agent,
724
+ LLM or Human (User). Note that `msg`, if passed, is treated as
725
+ message with role `user`; a "system" role message should not be
726
+ passed here.
727
+ turns (int): number of turns to run the task for;
728
+ default is -1, which means run until task is done.
729
+ caller (Task|None): the calling task, if any
730
+ max_cost (float): max cost allowed for the task (default 0 -> no limit)
731
+ max_tokens (int): max tokens allowed for the task (default 0 -> no limit)
732
+ session_id (str): session id for the task
733
+
734
+ Returns:
735
+ Optional[ChatDocument]: valid result of the task.
736
+ """
737
+
738
+ # Even if the initial "sender" is not literally the USER (since the task could
739
+ # have come from another LLM), as far as this agent is concerned, the initial
740
+ # message can be considered to be from the USER
741
+ # (from the POV of this agent's LLM).
742
+
743
+ if (
744
+ self.restart
745
+ and caller is None
746
+ or self.config_sub_task.restart_as_subtask
747
+ and caller is not None
748
+ ):
749
+ # We are either at top level, with restart = True, OR
750
+ # we are a sub-task with restart_as_subtask = True,
751
+ # so reset own agent and recursively for all sub-tasks
752
+ self.reset_all_sub_tasks()
753
+
754
+ self.n_stalled_steps = 0
755
+ self._no_answer_step = -5 # last step where the best explicit response was N/A
756
+ # how many N/A alternations have we had so far? (for Inf loop detection)
757
+ self.n_no_answer_alternations = 0
758
+ self.max_cost = max_cost
759
+ self.max_tokens = max_tokens
760
+ self.session_id = session_id
761
+ self._set_alive()
762
+ self._init_message_counter()
763
+ self.history.clear()
764
+
765
+ if (
766
+ isinstance(msg, ChatDocument)
767
+ and msg.metadata.recipient != ""
768
+ and msg.metadata.recipient != self.name
769
+ ):
770
+ # this task is not the intended recipient so return None
771
+ return None
772
+ self._pre_run_loop(
773
+ msg=msg,
774
+ caller=caller,
775
+ is_async=True,
776
+ )
777
+ self.scheduler_async = self.scheduler_class(
778
+ self.non_human_responders_async.copy(),
779
+ heads=len(
780
+ [r for r in self.agent.entity_responders_async() if r[0] != Entity.USER]
781
+ ),
782
+ )
783
+ # self.turns overrides if it is > 0 and turns not set (i.e. = -1)
784
+ turns = self.turns if turns < 0 else turns
785
+ i = 0
786
+ while True:
787
+ self._step_idx = i # used in step() below
788
+ await self.step_async()
789
+ await asyncio.sleep(0.01) # temp yield to avoid blocking
790
+ done, status = self.done()
791
+ if done:
792
+ if self._level == 0 and not settings.quiet:
793
+ print("[magenta]Bye, hope this was useful!")
794
+ break
795
+ i += 1
796
+ max_turns = (
797
+ min(turns, settings.max_turns)
798
+ if turns > 0 and settings.max_turns > 0
799
+ else max(turns, settings.max_turns)
800
+ )
801
+ if max_turns > 0 and i >= max_turns:
802
+ # Important to distinguish between:
803
+ # (a) intentional run for a
804
+ # fixed number of turns, where we expect the pending message
805
+ # at that stage to be the desired result, and
806
+ # (b) hitting max_turns limit, which is not intentional, and is an
807
+ # exception, resulting in a None task result
808
+ status = (
809
+ StatusCode.MAX_TURNS
810
+ if i == settings.max_turns
811
+ else StatusCode.FIXED_TURNS
812
+ )
813
+ break
814
+ if (
815
+ self.config.inf_loop_cycle_len > 0
816
+ and i % self.config.inf_loop_cycle_len == 0
817
+ and self._maybe_infinite_loop()
818
+ or self.n_no_answer_alternations > self.config.inf_loop_wait_factor
819
+ ):
820
+ raise InfiniteLoopException(
821
+ """Possible infinite loop detected!
822
+ You can adjust infinite loop detection (or turn it off)
823
+ by changing the params in the TaskConfig passed to the Task
824
+ constructor; see here:
825
+ https://langroid.github.io/langroid/reference/agent/task/#langroid.agent.task.TaskConfig
826
+ """
827
+ )
828
+
829
+ final_result = self.result(status)
830
+ self._post_run_loop()
831
+ return final_result
832
+
833
+ def _pre_run_loop(
834
+ self,
835
+ msg: Optional[str | ChatDocument] = None,
836
+ caller: None | Task = None,
837
+ is_async: bool = False,
838
+ ) -> None:
839
+ self.caller = caller
840
+ self.init(msg)
841
+ # sets indentation to be printed prior to any output from agent
842
+ self.agent.indent = self._indent
843
+ self.message_history_idx = -1
844
+ if isinstance(self.agent, ChatAgent):
845
+ # mark where we are in the message history, so we can reset to this when
846
+ # we are done with the task
847
+ self.message_history_idx = (
848
+ max(
849
+ len(self.agent.message_history),
850
+ len(self.agent.task_messages),
851
+ )
852
+ - 1
853
+ )
854
+ # TODO decide on whether or not to print, based on is_async
855
+ llm_model = (
856
+ "no-LLM" if self.agent.llm is None else self.agent.llm.config.chat_model
857
+ )
858
+ if not settings.quiet:
859
+ print(
860
+ f"[bold magenta]{self._enter} Starting Agent "
861
+ f"{self.name} ({self.message_history_idx+1}) "
862
+ f"{llm_model} [/bold magenta]"
863
+ )
864
+
865
+ def _post_run_loop(self) -> None:
866
+ # delete all messages from our agent's history, AFTER the first incoming
867
+ # message, and BEFORE final result message
868
+ n_messages = 0
869
+ if isinstance(self.agent, ChatAgent):
870
+ if self.erase_substeps:
871
+ # TODO I don't like directly accessing agent message_history. Revisit.
872
+ # (Pchalasani)
873
+ # Note: msg history will consist of:
874
+ # - H: the original msg history, ending at idx= self.message_history_idx
875
+ # - R: this agent's response, which presumably leads to:
876
+ # - X: a series of back-and-forth msgs (including with agent's own
877
+ # responders and with sub-tasks)
878
+ # - F: the final result message, from this agent.
879
+ # Here we are deleting all of [X] from the agent's message history,
880
+ # so that it simply looks as if the sub-tasks never happened.
881
+
882
+ dropped = self.agent.message_history[
883
+ self.message_history_idx + 2 : n_messages - 1
884
+ ]
885
+ # first delete the linked ChatDocuments (and descendants) from
886
+ # ObjectRegistry
887
+ for msg in dropped:
888
+ ChatDocument.delete_id(msg.chat_document_id)
889
+ # then delete the messages from the agent's message_history
890
+ del self.agent.message_history[
891
+ self.message_history_idx + 2 : n_messages - 1
892
+ ]
893
+ n_messages = len(self.agent.message_history)
894
+ if self.erase_substeps:
895
+ for t in self.sub_tasks:
896
+ # erase our conversation with agent of subtask t
897
+
898
+ # erase message_history of agent of subtask t
899
+ # TODO - here we assume that subtask-agents are
900
+ # ONLY talking to the current agent.
901
+ if isinstance(t.agent, ChatAgent):
902
+ t.agent.clear_history(0)
903
+ if not settings.quiet:
904
+ print(
905
+ f"[bold magenta]{self._leave} Finished Agent "
906
+ f"{self.name} ({n_messages}) [/bold magenta]"
907
+ )
908
+
909
+ def step(self, turns: int = -1) -> ChatDocument | None:
910
+ """
911
+ Synchronous version of `step_async()`. See `step_async()` for details.
912
+ TODO: Except for the self.response() calls, this fn should be identical to
913
+ `step_async()`. Consider refactoring to avoid duplication.
914
+ """
915
+ self.is_done = False
916
+ parent = self.pending_message
917
+ recipient = (
918
+ ""
919
+ if self.pending_message is None
920
+ else self.pending_message.metadata.recipient
921
+ )
922
+ if not self._valid_recipient(recipient):
923
+ logger.warning(f"Invalid recipient: {recipient}")
924
+ error_doc = ChatDocument(
925
+ content=f"Invalid recipient: {recipient}",
926
+ metadata=ChatDocMetaData(
927
+ sender=Entity.AGENT,
928
+ sender_name=Entity.AGENT,
929
+ ),
930
+ )
931
+ self._process_valid_responder_result(Entity.AGENT, parent, error_doc)
932
+ return error_doc
933
+
934
+ self.scheduler.init_step()
935
+
936
+ if (
937
+ Entity.USER in self.responders
938
+ and not self.human_tried
939
+ and not self.agent.has_tool_message_attempt(self.pending_message)
940
+ ):
941
+ # When in interactive mode,
942
+ # Give human first chance if they haven't been tried in last step,
943
+ # and the msg is not a tool-call attempt;
944
+ # This ensures human gets a chance to respond,
945
+ # other than to a LLM tool-call.
946
+ # When there's a tool msg attempt we want the
947
+ # Agent to be the next responder; this only makes a difference in an
948
+ # interactive setting: LLM generates tool, then we don't want user to
949
+ # have to respond, and instead let the agent_response handle the tool.
950
+ self.scheduler.remove(Entity.USER)
951
+ self.scheduler.add_first(Entity.USER, head=True)
952
+
953
+ found_response = False
954
+ # (responder, result) from a responder who explicitly said NO_ANSWER
955
+ no_answer_response: None | Tuple[Responder, ChatDocument] = None
956
+ while (r := self.scheduler.next()) is not None:
957
+ self.is_pass_thru = False
958
+ if not self._can_respond(r):
959
+ # create dummy msg for logging
960
+ log_doc = ChatDocument(
961
+ content="[CANNOT RESPOND]",
962
+ function_call=None,
963
+ metadata=ChatDocMetaData(
964
+ sender=r if isinstance(r, Entity) else Entity.USER,
965
+ sender_name=str(r),
966
+ recipient=recipient,
967
+ ),
968
+ )
969
+ # no need to register this dummy msg in ObjectRegistry
970
+ ChatDocument.delete_id(log_doc.id())
971
+ self.log_message(r, log_doc)
972
+ continue
973
+ self.human_tried = r == Entity.USER
974
+ result = self.response(r, turns)
975
+ if result and NO_ANSWER in result.content:
976
+ no_answer_response = (r, result)
977
+ self.is_done = self._is_done_response(result, r)
978
+ self.is_pass_thru = PASS in result.content if result else False
979
+ if self.valid(result, r):
980
+ found_response = True
981
+ assert result is not None
982
+ self._process_valid_responder_result(r, parent, result)
983
+ break
984
+ else:
985
+ self.log_message(r, result)
986
+ if self.is_done:
987
+ # skip trying other responders in this step
988
+ break
989
+ if not found_response: # did not find a valid response
990
+ if no_answer_response:
991
+ # even though there was no valid response from anyone in this step,
992
+ # if there was at least one who EXPLICITLY said NO_ANSWER, then
993
+ # we process that as a valid response.
994
+ r, result = no_answer_response
995
+ self._process_valid_responder_result(r, parent, result)
996
+ else:
997
+ self._process_invalid_step_result(parent)
998
+ self._show_pending_message_if_debug()
999
+ return self.pending_message
1000
+
1001
+ async def step_async(self, turns: int = -1) -> ChatDocument | None:
1002
+ """
1003
+ A single "turn" in the task conversation: The "allowed" responders in this
1004
+ turn (which can be either the 3 "entities", or one of the sub-tasks) are
1005
+ tried in sequence, until a _valid_ response is obtained; a _valid_
1006
+ response is one that contributes to the task, either by ending it,
1007
+ or producing a response to be further acted on.
1008
+ Update `self.pending_message` to the latest valid response (or NO_ANSWER
1009
+ if no valid response was obtained from any responder).
1010
+
1011
+ Args:
1012
+ turns (int): number of turns to process. Typically used in testing
1013
+ where there is no human to "quit out" of current level, or in cases
1014
+ where we want to limit the number of turns of a delegated agent.
1015
+
1016
+ Returns (ChatDocument|None):
1017
+ Updated `self.pending_message`. Currently the return value is not used
1018
+ by the `task.run()` method, but we return this as a convenience for
1019
+ other use-cases, e.g. where we want to run a task step by step in a
1020
+ different context.
1021
+ """
1022
+ self.is_done = False
1023
+ parent = self.pending_message
1024
+ recipient = (
1025
+ ""
1026
+ if self.pending_message is None
1027
+ else self.pending_message.metadata.recipient
1028
+ )
1029
+ if not self._valid_recipient(recipient):
1030
+ logger.warning(f"Invalid recipient: {recipient}")
1031
+ error_doc = ChatDocument(
1032
+ content=f"Invalid recipient: {recipient}",
1033
+ metadata=ChatDocMetaData(
1034
+ sender=Entity.AGENT,
1035
+ sender_name=Entity.AGENT,
1036
+ ),
1037
+ )
1038
+ self._process_valid_responder_result(Entity.AGENT, parent, error_doc)
1039
+ return error_doc
1040
+
1041
+ self.scheduler_async.init_step()
1042
+ if (
1043
+ Entity.USER in self.responders
1044
+ and not self.human_tried
1045
+ and not self.agent.has_tool_message_attempt(self.pending_message)
1046
+ ):
1047
+ # Give human first chance if they haven't been tried in last step,
1048
+ # and the msg is not a tool-call attempt;
1049
+ # This ensures human gets a chance to respond,
1050
+ # other than to a LLM tool-call.
1051
+ # When there's a tool msg attempt we want the
1052
+ # Agent to be the next responder; this only makes a difference in an
1053
+ # interactive setting: LLM generates tool, then we don't want user to
1054
+ # have to respond, and instead let the agent_response handle the tool.
1055
+ self.scheduler_async.remove(Entity.USER)
1056
+ self.scheduler_async.add_first(Entity.USER, head=True)
1057
+
1058
+ found_response = False
1059
+ # (responder, result) from a responder who explicitly said NO_ANSWER
1060
+ no_answer_response: None | Tuple[Responder, ChatDocument] = None
1061
+
1062
+ while (r := self.scheduler_async.next()) is not None:
1063
+ if not self._can_respond(r):
1064
+ # create dummy msg for logging
1065
+ log_doc = ChatDocument(
1066
+ content="[CANNOT RESPOND]",
1067
+ function_call=None,
1068
+ metadata=ChatDocMetaData(
1069
+ sender=r if isinstance(r, Entity) else Entity.USER,
1070
+ sender_name=str(r),
1071
+ recipient=recipient,
1072
+ ),
1073
+ )
1074
+ # no need to register this dummy msg in ObjectRegistry
1075
+ ChatDocument.delete_id(log_doc.id())
1076
+ self.log_message(r, log_doc)
1077
+ continue
1078
+ self.human_tried = r == Entity.USER
1079
+ result = await self.response_async(r, turns)
1080
+ if result and NO_ANSWER in result.content:
1081
+ no_answer_response = (r, result)
1082
+ self.is_done = self._is_done_response(result, r)
1083
+ self.is_pass_thru = PASS in result.content if result else False
1084
+ if self.valid(result, r):
1085
+ found_response = True
1086
+ assert result is not None
1087
+ self._process_valid_responder_result(r, parent, result)
1088
+ break
1089
+ else:
1090
+ self.log_message(r, result)
1091
+ if self.is_done:
1092
+ # skip trying other responders in this step
1093
+ break
1094
+ if not found_response:
1095
+ if no_answer_response:
1096
+ # even though there was no valid response from anyone in this step,
1097
+ # if there was at least one who EXPLICITLY said NO_ANSWER, then
1098
+ # we process that as a valid response.
1099
+ r, result = no_answer_response
1100
+ self._process_valid_responder_result(r, parent, result)
1101
+ else:
1102
+ self._process_invalid_step_result(parent)
1103
+ self._show_pending_message_if_debug()
1104
+ return self.pending_message
1105
+
1106
+ def _update_no_answer_vars(self, result: ChatDocument) -> None:
1107
+ """Update variables related to NO_ANSWER responses, to aid
1108
+ in alternating NO_ANSWER infinite-loop detection."""
1109
+
1110
+ if NO_ANSWER in result.content:
1111
+ if self._no_answer_step == self._step_idx - 2:
1112
+ # N/A two steps ago
1113
+ self.n_no_answer_alternations += 1
1114
+ else:
1115
+ # reset alternations counter
1116
+ self.n_no_answer_alternations = 0
1117
+
1118
+ # record the last step where the best explicit response was N/A
1119
+ self._no_answer_step = self._step_idx
1120
+
1121
+ def _process_valid_responder_result(
1122
+ self,
1123
+ r: Responder,
1124
+ parent: ChatDocument | None,
1125
+ result: ChatDocument,
1126
+ ) -> None:
1127
+ """Processes valid result from a responder, during a step"""
1128
+
1129
+ self._update_no_answer_vars(result)
1130
+
1131
+ # pending_sender is of type Responder,
1132
+ # i.e. it is either one of the agent's entities
1133
+ # OR a sub-task, that has produced a valid response.
1134
+ # Contrast this with self.pending_message.metadata.sender, which is an ENTITY
1135
+ # of this agent, or a sub-task's agent.
1136
+ if not self.is_pass_thru:
1137
+ if (
1138
+ self.pending_message is not None
1139
+ and self.pending_message.metadata.agent_id == self.agent.id
1140
+ ):
1141
+ # when pending msg is from our own agent, respect the sender set there,
1142
+ # since sometimes a response may "mock" as if the response is from
1143
+ # another entity (e.g when using RewindTool, the agent handler
1144
+ # returns a result as if it were from the LLM).
1145
+ self.pending_sender = result.metadata.sender
1146
+ else:
1147
+ # when pending msg is from a sub-task, the sender is the sub-task
1148
+ self.pending_sender = r
1149
+ self.pending_message = result
1150
+ # set the parent/child links ONLY if not already set by agent internally,
1151
+ # which may happen when using the RewindTool
1152
+ if parent is not None and not result.metadata.parent_id:
1153
+ result.metadata.parent_id = parent.id()
1154
+ if parent is not None and not parent.metadata.child_id:
1155
+ parent.metadata.child_id = result.id()
1156
+
1157
+ self.log_message(self.pending_sender, result, mark=True)
1158
+ if self.is_pass_thru:
1159
+ self.n_stalled_steps += 1
1160
+ else:
1161
+ # reset stuck counter since we made progress
1162
+ self.n_stalled_steps = 0
1163
+
1164
+ # update counters for infinite loop detection
1165
+ if self.pending_message is not None:
1166
+ hashed_msg = hash(str(self.pending_message))
1167
+ self.message_counter.update([hashed_msg])
1168
+ self.history.append(hashed_msg)
1169
+
1170
+ def _process_invalid_step_result(self, parent: ChatDocument | None) -> None:
1171
+ """
1172
+ Since step had no valid result from any responder, decide whether to update the
1173
+ self.pending_message to a NO_ANSWER message from the opposite entity,
1174
+ or leave it as is.
1175
+ Args:
1176
+ parent (ChatDocument|None): parent message of the current message
1177
+ """
1178
+ self.n_stalled_steps += 1
1179
+ if self.allow_null_result and not self.is_pass_thru:
1180
+ # Null step-result is allowed, and we're not in a "pass-thru" situation,
1181
+ # so we update the pending_message to a dummy NO_ANSWER msg
1182
+ # from the entity 'opposite' to the current pending_sender,
1183
+ # so that the task can continue.
1184
+ # CAUTION: unless the LLM is instructed to signal DONE at an appropriate
1185
+ # time, this can result in an infinite loop.
1186
+ responder = (
1187
+ Entity.LLM if self.pending_sender == Entity.USER else Entity.USER
1188
+ )
1189
+ parent_id = "" if parent is None else parent.id()
1190
+ self.pending_message = ChatDocument(
1191
+ content=NO_ANSWER,
1192
+ metadata=ChatDocMetaData(sender=responder, parent_id=parent_id),
1193
+ )
1194
+ self.pending_sender = responder
1195
+ self._update_no_answer_vars(self.pending_message)
1196
+ self.log_message(self.pending_sender, self.pending_message, mark=True)
1197
+
1198
+ def _show_pending_message_if_debug(self) -> None:
1199
+ if self.pending_message is None:
1200
+ return
1201
+ if settings.debug:
1202
+ sender_str = escape(str(self.pending_sender))
1203
+ msg_str = escape(str(self.pending_message))
1204
+ print(f"[grey37][{sender_str}]{msg_str}[/grey37]")
1205
+
1206
+ def response(
1207
+ self,
1208
+ e: Responder,
1209
+ turns: int = -1,
1210
+ ) -> Optional[ChatDocument]:
1211
+ """
1212
+ Sync version of `response_async()`. See `response_async()` for details.
1213
+ """
1214
+ if isinstance(e, Task):
1215
+ actual_turns = e.turns if e.turns > 0 else turns
1216
+ e.agent.callbacks.set_parent_agent(self.agent)
1217
+ # e.callbacks.set_parent_agent(self.agent)
1218
+ result = e.run(
1219
+ self.pending_message,
1220
+ turns=actual_turns,
1221
+ caller=self,
1222
+ max_cost=self.max_cost,
1223
+ max_tokens=self.max_tokens,
1224
+ )
1225
+ result_str = ( # only used by callback to display content and possible tool
1226
+ "NONE" if result is None else str(ChatDocument.to_LLMMessage(result))
1227
+ )
1228
+ maybe_tool = len(extract_top_level_json(result_str)) > 0
1229
+ self.callbacks.show_subtask_response(
1230
+ task=e,
1231
+ content=result_str,
1232
+ is_tool=maybe_tool,
1233
+ )
1234
+ else:
1235
+ response_fn = self._entity_responder_map[cast(Entity, e)]
1236
+ result = response_fn(self.pending_message)
1237
+ return self._process_result_routing(result)
1238
+
1239
+ def _process_result_routing(
1240
+ self, result: ChatDocument | None
1241
+ ) -> ChatDocument | None:
1242
+ # process result in case there is a routing instruction
1243
+ if result is None:
1244
+ return None
1245
+ # if result content starts with @name, set recipient to name
1246
+ is_pass, recipient, content = parse_routing(
1247
+ result,
1248
+ addressing_prefix=self.config.addressing_prefix,
1249
+ )
1250
+ if is_pass is None: # no routing, i.e. neither PASS nor SEND
1251
+ return result
1252
+ if is_pass:
1253
+ if recipient is None or self.pending_message is None:
1254
+ # Just PASS, no recipient
1255
+ # This means pass on self.pending_message to the next responder
1256
+ # in the default sequence of responders.
1257
+ # So leave result intact since we handle "PASS" in step()
1258
+ return result
1259
+ # set recipient in self.pending_message
1260
+ self.pending_message.metadata.recipient = recipient
1261
+ # clear out recipient, replace with just PASS
1262
+ result.content = result.content.replace(
1263
+ f"{PASS_TO}:{recipient}", PASS
1264
+ ).strip()
1265
+ return result
1266
+ elif recipient is not None:
1267
+ # we are sending non-empty content to non-null recipient
1268
+ # clean up result.content, set metadata.recipient and return
1269
+ result.content = content or ""
1270
+ result.metadata.recipient = recipient
1271
+ return result
1272
+ else:
1273
+ return result
1274
+
1275
+ async def response_async(
1276
+ self,
1277
+ e: Responder,
1278
+ turns: int = -1,
1279
+ ) -> Optional[ChatDocument]:
1280
+ """
1281
+ Get response to `self.pending_message` from a responder.
1282
+ If response is __valid__ (i.e. it ends the current turn of seeking
1283
+ responses):
1284
+ -then return the response as a ChatDocument object,
1285
+ -otherwise return None.
1286
+ Args:
1287
+ e (Responder): responder to get response from.
1288
+ turns (int): number of turns to run the task for.
1289
+ Default is -1, which means run until task is done.
1290
+
1291
+ Returns:
1292
+ Optional[ChatDocument]: response to `self.pending_message` from entity if
1293
+ valid, None otherwise
1294
+ """
1295
+ if isinstance(e, Task):
1296
+ actual_turns = e.turns if e.turns > 0 else turns
1297
+ e.agent.callbacks.set_parent_agent(self.agent)
1298
+ # e.callbacks.set_parent_agent(self.agent)
1299
+ result = await e.run_async(
1300
+ self.pending_message,
1301
+ turns=actual_turns,
1302
+ caller=self,
1303
+ max_cost=self.max_cost,
1304
+ max_tokens=self.max_tokens,
1305
+ )
1306
+ result_str = str(ChatDocument.to_LLMMessage(result))
1307
+ maybe_tool = len(extract_top_level_json(result_str)) > 0
1308
+ self.callbacks.show_subtask_response(
1309
+ task=e,
1310
+ content=result_str,
1311
+ is_tool=maybe_tool,
1312
+ )
1313
+ else:
1314
+ response_fn = self._entity_responder_async_map[cast(Entity, e)]
1315
+ result = await response_fn(self.pending_message)
1316
+ return self._process_result_routing(result)
1317
+
1318
+ def result(self, status: StatusCode | None = None) -> ChatDocument | None:
1319
+ """
1320
+ Get result of task. This is the default behavior.
1321
+ Derived classes can override this.
1322
+
1323
+ Note the result of a task is returned as if it is from the User entity.
1324
+
1325
+ Args:
1326
+ status (StatusCode): status of the task when it ended
1327
+ Returns:
1328
+ ChatDocument: result of task
1329
+ """
1330
+ if status in [StatusCode.STALLED, StatusCode.MAX_TURNS, StatusCode.INF_LOOP]:
1331
+ # In these case we don't know (and don't want to try to guess)
1332
+ # what the task result should be, so we return None
1333
+ return None
1334
+
1335
+ result_msg = self.pending_message
1336
+
1337
+ content = result_msg.content if result_msg else ""
1338
+ if DONE in content:
1339
+ # assuming it is of the form "DONE: <content>"
1340
+ content = content.replace(DONE, "").strip()
1341
+ fun_call = result_msg.function_call if result_msg else None
1342
+ tool_messages = result_msg.tool_messages if result_msg else []
1343
+ block = result_msg.metadata.block if result_msg else None
1344
+ recipient = result_msg.metadata.recipient if result_msg else ""
1345
+ tool_ids = result_msg.metadata.tool_ids if result_msg else []
1346
+
1347
+ # regardless of which entity actually produced the result,
1348
+ # when we return the result, we set entity to USER
1349
+ # since to the "parent" task, this result is equivalent to a response from USER
1350
+ result_doc = ChatDocument(
1351
+ content=content,
1352
+ function_call=fun_call,
1353
+ tool_messages=tool_messages,
1354
+ metadata=ChatDocMetaData(
1355
+ source=Entity.USER,
1356
+ sender=Entity.USER,
1357
+ block=block,
1358
+ status=status or (result_msg.metadata.status if result_msg else None),
1359
+ sender_name=self.name,
1360
+ recipient=recipient,
1361
+ tool_ids=tool_ids,
1362
+ parent_id=result_msg.id() if result_msg else "",
1363
+ agent_id=str(self.agent.id),
1364
+ ),
1365
+ )
1366
+ if self.pending_message is not None:
1367
+ self.pending_message.metadata.child_id = result_doc.id()
1368
+
1369
+ return result_doc
1370
+
1371
+ def _is_empty_message(self, msg: str | ChatDocument | None) -> bool:
1372
+ """
1373
+ Check if msg is empty or None
1374
+ Args:
1375
+ msg (str|ChatDocument|None): message to check
1376
+ Returns:
1377
+ bool: True if msg is (equivalent to) empty or None, False otherwise
1378
+ """
1379
+ return (
1380
+ msg is None
1381
+ or (isinstance(msg, str) and msg.strip() in [PASS, ""])
1382
+ or (
1383
+ isinstance(msg, ChatDocument)
1384
+ and msg.content.strip() in [PASS, ""]
1385
+ and msg.function_call is None
1386
+ and msg.tool_messages == []
1387
+ )
1388
+ )
1389
+
1390
+ def _is_done_response(
1391
+ self, result: str | None | ChatDocument, responder: Responder
1392
+ ) -> bool:
1393
+ """Is the task done based on the response from the given responder?"""
1394
+
1395
+ response_says_done = result is not None and (
1396
+ (isinstance(result, str) and DONE in result)
1397
+ or (isinstance(result, ChatDocument) and DONE in result.content)
1398
+ )
1399
+ return (
1400
+ (
1401
+ responder.value in self.done_if_response
1402
+ and not self._is_empty_message(result)
1403
+ )
1404
+ or (
1405
+ responder.value in self.done_if_no_response
1406
+ and self._is_empty_message(result)
1407
+ )
1408
+ or (not self._is_empty_message(result) and response_says_done)
1409
+ )
1410
+
1411
+ def _maybe_infinite_loop(self) -> bool:
1412
+ """
1413
+ Detect possible infinite loop based on message frequencies.
1414
+ NOTE: This detects two types of loops:
1415
+ - Alternating NO_ANSWER loops, specifically of the form
1416
+ x1 NO_ANSWER x2 NO_ANSWER x3 NO_ANSWER...
1417
+ (e.g. an LLM repeatedly saying something different, and another responder
1418
+ or sub-task saying NO_ANSWER -- i.e. "DO-NOT-KNOW")
1419
+
1420
+ - "exact" loops, i.e. a cycle of messages that repeats exactly, e.g.
1421
+ a r b i t r a t e r a t e r a t e r a t e ...
1422
+
1423
+ [It does not detect more general "approximate" loops, where two entities are
1424
+ responding to each other potentially forever, with (slightly) different
1425
+ messages each time]
1426
+
1427
+ Here is the logic for the exact-loop detection:
1428
+ Intuition: when you look at a sufficiently long sequence with an m-message
1429
+ loop, then the frequencies of these m messages will "dominate" those
1430
+ of all other messages.
1431
+
1432
+ 1. First find m "dominant" messages, i.e. when arranged in decreasing
1433
+ frequency order, find the m such that
1434
+ freq[m] > F * freq[m+1] and
1435
+ freq[m] > W + freq[m+1]
1436
+ where F = config.inf_loop_dominance_factor (default 1.5) and
1437
+ W = config.inf_loop_wait_factor (default 5).
1438
+ So if you plot these frequencies in decreasing order,
1439
+ you will see a big drop in the plot, from m to m+1.
1440
+ We call the freqs until m the "dominant" freqs.
1441
+ 2. Say we found m such dominant messages
1442
+ If the set of last (W * m) messages are the same as the
1443
+ set of m dominant messages, then we are likely in a loop.
1444
+ """
1445
+
1446
+ max_cycle_len = self.config.inf_loop_cycle_len
1447
+ if max_cycle_len <= 0:
1448
+ # no loop detection
1449
+ return False
1450
+ wait_factor = self.config.inf_loop_wait_factor
1451
+ if sum(self.message_counter.values()) < wait_factor * max_cycle_len:
1452
+ # we haven't seen enough messages to detect a loop
1453
+ return False
1454
+
1455
+ # recall there's always a dummy msg with freq = 1
1456
+ most_common_msg_counts: List[Tuple[str, int]] = (
1457
+ self.message_counter.most_common(max_cycle_len + 1)
1458
+ )
1459
+ # get the most dominant msgs, i.e. these are at least 1.5x more freq
1460
+ # than the rest
1461
+ F = self.config.inf_loop_dominance_factor
1462
+ # counts array in non-increasing order
1463
+ counts = np.array([c for _, c in most_common_msg_counts])
1464
+ # find first index where counts[i] > F * counts[i+1]
1465
+ ratios = counts[:-1] / counts[1:]
1466
+ diffs = counts[:-1] - counts[1:]
1467
+ indices = np.where((ratios > F) & (diffs > wait_factor))[0]
1468
+ m = indices[-1] if indices.size > 0 else -1
1469
+ if m < 0:
1470
+ # no dominance found, but...
1471
+ if len(most_common_msg_counts) <= max_cycle_len:
1472
+ # ...The most-common messages are at most max_cycle_len,
1473
+ # even though we looked for the most common (max_cycle_len + 1) msgs.
1474
+ # This means there are only at most max_cycle_len distinct messages,
1475
+ # which also indicates a possible loop.
1476
+ m = len(most_common_msg_counts) - 1
1477
+ else:
1478
+ # ... we have enough messages, but no dominance found,
1479
+ # so there COULD be loops longer than max_cycle_len,
1480
+ # OR there is no loop at all; we can't tell, so we return False.
1481
+ return False
1482
+
1483
+ dominant_msg_counts = most_common_msg_counts[: m + 1]
1484
+ # if the SET of dominant m messages is the same as the
1485
+ # the SET of last m*w messages, (where w = config.inf_loop_wait_factor),
1486
+ # then we are likely in a loop
1487
+ dominant_msgs = set([msg for msg, _ in dominant_msg_counts])
1488
+ lookback = wait_factor * (m + 1)
1489
+ recent_msgs = set(list(self.history)[-lookback:])
1490
+ return dominant_msgs == recent_msgs
1491
+
1492
+ def done(
1493
+ self, result: ChatDocument | None = None, r: Responder | None = None
1494
+ ) -> Tuple[bool, StatusCode]:
1495
+ """
1496
+ Check if task is done. This is the default behavior.
1497
+ Derived classes can override this.
1498
+ Args:
1499
+ result (ChatDocument|None): result from a responder
1500
+ r (Responder|None): responder that produced the result
1501
+ Not used here, but could be used by derived classes.
1502
+ Returns:
1503
+ bool: True if task is done, False otherwise
1504
+ StatusCode: status code indicating why task is done
1505
+ """
1506
+ if self._is_kill():
1507
+ return (True, StatusCode.KILL)
1508
+ result = result or self.pending_message
1509
+ user_quit = (
1510
+ result is not None
1511
+ and (result.content in USER_QUIT_STRINGS or DONE in result.content)
1512
+ and result.metadata.sender == Entity.USER
1513
+ )
1514
+ if self._level == 0 and self.interactive and self.only_user_quits_root:
1515
+ # for top-level task, in interactive mode, only user can quit out
1516
+ return (user_quit, StatusCode.USER_QUIT if user_quit else StatusCode.OK)
1517
+
1518
+ if self.is_done:
1519
+ return (True, StatusCode.DONE)
1520
+
1521
+ if self.n_stalled_steps >= self.max_stalled_steps:
1522
+ # we are stuck, so bail to avoid infinite loop
1523
+ logger.warning(
1524
+ f"Task {self.name} stuck for {self.max_stalled_steps} steps; exiting."
1525
+ )
1526
+ return (True, StatusCode.STALLED)
1527
+
1528
+ if self.max_cost > 0 and self.agent.llm is not None:
1529
+ try:
1530
+ if self.agent.llm.tot_tokens_cost()[1] > self.max_cost:
1531
+ logger.warning(
1532
+ f"Task {self.name} cost exceeded {self.max_cost}; exiting."
1533
+ )
1534
+ return (True, StatusCode.MAX_COST)
1535
+ except Exception:
1536
+ pass
1537
+
1538
+ if self.max_tokens > 0 and self.agent.llm is not None:
1539
+ try:
1540
+ if self.agent.llm.tot_tokens_cost()[0] > self.max_tokens:
1541
+ logger.warning(
1542
+ f"Task {self.name} uses > {self.max_tokens} tokens; exiting."
1543
+ )
1544
+ return (True, StatusCode.MAX_TOKENS)
1545
+ except Exception:
1546
+ pass
1547
+ final = (
1548
+ # no valid response from any entity/agent in current turn
1549
+ result is None
1550
+ # An entity decided task is done
1551
+ or DONE in result.content
1552
+ or ( # current task is addressing message to caller task
1553
+ self.caller is not None
1554
+ and self.caller.name != ""
1555
+ and result.metadata.recipient == self.caller.name
1556
+ )
1557
+ or user_quit
1558
+ )
1559
+ return (final, StatusCode.OK)
1560
+
1561
+ def valid(
1562
+ self,
1563
+ result: Optional[ChatDocument],
1564
+ r: Responder,
1565
+ ) -> bool:
1566
+ """
1567
+ Is the result from a Responder (i.e. an entity or sub-task)
1568
+ such that we can stop searching for responses in this step?
1569
+ """
1570
+ # TODO caution we should ensure that no handler method (tool) returns simply
1571
+ # an empty string (e.g when showing contents of an empty file), since that
1572
+ # would be considered an invalid response, and other responders will wrongly
1573
+ # be given a chance to respond.
1574
+
1575
+ # if task would be considered done given responder r's `result`,
1576
+ # then consider the result valid.
1577
+ if result is not None and self.done(result, r)[0]:
1578
+ return True
1579
+ return (
1580
+ result is not None
1581
+ and not self._is_empty_message(result)
1582
+ # some weaker LLMs, including even GPT-4o, may say "DO-NOT-KNOW."
1583
+ # (with a punctuation at the end), so need to strip out punctuation
1584
+ and re.sub(r"[,.!?:]", "", result.content.strip()) != NO_ANSWER
1585
+ )
1586
+
1587
+ def log_message(
1588
+ self,
1589
+ resp: Responder,
1590
+ msg: ChatDocument | None = None,
1591
+ mark: bool = False,
1592
+ ) -> None:
1593
+ """
1594
+ Log current pending message, and related state, for lineage/debugging purposes.
1595
+
1596
+ Args:
1597
+ resp (Responder): Responder that generated the `msg`
1598
+ msg (ChatDocument, optional): Message to log. Defaults to None.
1599
+ mark (bool, optional): Whether to mark the message as the final result of
1600
+ a `task.step()` call. Defaults to False.
1601
+ """
1602
+ default_values = ChatDocLoggerFields().dict().values()
1603
+ msg_str_tsv = "\t".join(str(v) for v in default_values)
1604
+ if msg is not None:
1605
+ msg_str_tsv = msg.tsv_str()
1606
+
1607
+ mark_str = "*" if mark else " "
1608
+ task_name = self.name if self.name != "" else "root"
1609
+ resp_color = "white" if mark else "red"
1610
+ resp_str = f"[{resp_color}] {resp} [/{resp_color}]"
1611
+
1612
+ if msg is None:
1613
+ msg_str = f"{mark_str}({task_name}) {resp_str}"
1614
+ else:
1615
+ color = {
1616
+ Entity.LLM: "green",
1617
+ Entity.USER: "blue",
1618
+ Entity.AGENT: "red",
1619
+ Entity.SYSTEM: "magenta",
1620
+ }[msg.metadata.sender]
1621
+ f = msg.log_fields()
1622
+ tool_type = f.tool_type.rjust(6)
1623
+ tool_name = f.tool.rjust(10)
1624
+ tool_str = f"{tool_type}({tool_name})" if tool_name != "" else ""
1625
+ sender = f"[{color}]" + str(f.sender_entity).rjust(10) + f"[/{color}]"
1626
+ sender_name = f.sender_name.rjust(10)
1627
+ recipient = "=>" + str(f.recipient).rjust(10)
1628
+ block = "X " + str(f.block or "").rjust(10)
1629
+ content = f"[{color}]{f.content}[/{color}]"
1630
+ msg_str = (
1631
+ f"{mark_str}({task_name}) "
1632
+ f"{resp_str} {sender}({sender_name}) "
1633
+ f"({recipient}) ({block}) {tool_str} {content}"
1634
+ )
1635
+
1636
+ if self.logger is not None:
1637
+ self.logger.log(msg_str)
1638
+ if self.tsv_logger is not None:
1639
+ resp_str = str(resp)
1640
+ self.tsv_logger.info(f"{mark_str}\t{task_name}\t{resp_str}\t{msg_str_tsv}")
1641
+
1642
+ def _valid_recipient(self, recipient: str) -> bool:
1643
+ """
1644
+ Is the recipient among the list of responders?
1645
+ Args:
1646
+ recipient (str): Name of recipient
1647
+ """
1648
+ if recipient == "":
1649
+ return True
1650
+ # native responders names are USER, LLM, AGENT,
1651
+ # and the names of subtasks are from Task.name attribute
1652
+ responder_names = [self.name.lower()] + [
1653
+ r.name.lower() for r in self.responders
1654
+ ]
1655
+ return recipient.lower() in responder_names
1656
+
1657
+ def _recipient_mismatch(self, e: Responder) -> bool:
1658
+ """
1659
+ Is the recipient explicitly specified and does not match responder "e" ?
1660
+ """
1661
+ # Note that recipient could be specified as an Entity or a Task name
1662
+ return (
1663
+ self.pending_message is not None
1664
+ and (recipient := self.pending_message.metadata.recipient) != ""
1665
+ and not (recipient == e) # case insensitive for entities
1666
+ and recipient != e.name
1667
+ and recipient != self.name # case sensitive
1668
+ )
1669
+
1670
+ def _can_respond(self, e: Responder) -> bool:
1671
+ user_can_respond = self.interactive or (
1672
+ # regardless of self.interactive, if a msg is explicitly addressed to
1673
+ # user, then wait for user response
1674
+ self.pending_message is not None
1675
+ and self.pending_message.metadata.recipient == Entity.USER
1676
+ )
1677
+
1678
+ if self.pending_sender == e or (e == Entity.USER and not user_can_respond):
1679
+ # sender is same as e (an entity cannot respond to its own msg),
1680
+ # or user cannot respond
1681
+ return False
1682
+
1683
+ if self.pending_message is None:
1684
+ return True
1685
+ if self._recipient_mismatch(e):
1686
+ # Cannot respond if not addressed to this entity
1687
+ return False
1688
+ return self.pending_message.metadata.block != e
1689
+
1690
+ def set_color_log(self, enable: bool = True) -> None:
1691
+ """
1692
+ Flag to enable/disable color logging using rich.console.
1693
+ In some contexts, such as Colab notebooks, we may want to disable color logging
1694
+ using rich.console, since those logs show up in the cell output rather than
1695
+ in the log file. Turning off this feature will still create logs, but without
1696
+ the color formatting from rich.console
1697
+ Args:
1698
+ enable (bool): value of `self.color_log` to set to,
1699
+ which will enable/diable rich logging
1700
+
1701
+ """
1702
+ self.color_log = enable
1703
+
1704
+
1705
+ def parse_routing(
1706
+ msg: ChatDocument | str,
1707
+ addressing_prefix: str = "",
1708
+ ) -> Tuple[bool | None, str | None, str | None]:
1709
+ """
1710
+ Parse routing instruction if any, of the form:
1711
+ PASS:<recipient> (pass current pending msg to recipient)
1712
+ SEND:<recipient> <content> (send content to recipient)
1713
+ @<recipient> <content> (send content to recipient)
1714
+ Args:
1715
+ msg (ChatDocument|str|None): message to parse
1716
+ addressing_prefix (str): prefix to address other agents or entities,
1717
+ (e.g. "@". See documentation of `TaskConfig` for details).
1718
+ Returns:
1719
+ Tuple[bool|None, str|None, str|None]:
1720
+ bool: true=PASS, false=SEND, or None if neither
1721
+ str: recipient, or None
1722
+ str: content to send, or None
1723
+ """
1724
+ # handle routing instruction in result if any,
1725
+ # of the form PASS=<recipient>
1726
+ content = msg.content if isinstance(msg, ChatDocument) else msg
1727
+ content = content.strip()
1728
+ if PASS in content and PASS_TO not in content:
1729
+ return True, None, None
1730
+ if PASS_TO in content and content.split(":")[1] != "":
1731
+ return True, content.split(":")[1], None
1732
+ if (
1733
+ SEND_TO in content
1734
+ and (addressee_content := parse_addressed_message(content, SEND_TO))[0]
1735
+ is not None
1736
+ ):
1737
+ (addressee, content_to_send) = addressee_content
1738
+ # if no content then treat same as PASS_TO
1739
+ if content_to_send == "":
1740
+ return True, addressee, None
1741
+ else:
1742
+ return False, addressee, content_to_send
1743
+ if (
1744
+ addressing_prefix != ""
1745
+ and addressing_prefix in content
1746
+ and (addressee_content := parse_addressed_message(content, addressing_prefix))[
1747
+ 0
1748
+ ]
1749
+ is not None
1750
+ ):
1751
+ (addressee, content_to_send) = addressee_content
1752
+ # if no content then treat same as PASS_TO
1753
+ if content_to_send == "":
1754
+ return True, addressee, None
1755
+ else:
1756
+ return False, addressee, content_to_send
1757
+
1758
+ return None, None, None