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