langroid 0.31.2__py3-none-any.whl → 0.33.3__py3-none-any.whl

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