langroid 0.33.6__py3-none-any.whl → 0.33.8__py3-none-any.whl

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