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