langroid 0.1.265__py3-none-any.whl → 0.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- langroid/agent/base.py +21 -9
- langroid/agent/chat_agent.py +69 -17
- langroid/agent/chat_document.py +59 -4
- langroid/agent/special/doc_chat_agent.py +8 -26
- langroid/agent/task.py +299 -103
- langroid/agent/tools/__init__.py +4 -0
- langroid/agent/tools/rewind_tool.py +137 -0
- langroid/language_models/__init__.py +3 -0
- langroid/language_models/base.py +23 -4
- langroid/language_models/mock_lm.py +91 -0
- langroid/language_models/utils.py +2 -1
- langroid/mytypes.py +4 -35
- langroid/parsing/document_parser.py +5 -0
- langroid/parsing/parser.py +17 -2
- langroid/utils/__init__.py +2 -0
- langroid/utils/constants.py +2 -1
- langroid/utils/object_registry.py +66 -0
- langroid/utils/system.py +1 -2
- langroid/vector_store/base.py +3 -2
- {langroid-0.1.265.dist-info → langroid-0.2.2.dist-info}/METADATA +10 -6
- {langroid-0.1.265.dist-info → langroid-0.2.2.dist-info}/RECORD +24 -22
- pyproject.toml +2 -2
- langroid/language_models/openai_assistants.py +0 -3
- {langroid-0.1.265.dist-info → langroid-0.2.2.dist-info}/LICENSE +0 -0
- {langroid-0.1.265.dist-info → langroid-0.2.2.dist-info}/WHEEL +0 -0
langroid/agent/task.py
CHANGED
@@ -3,7 +3,9 @@ from __future__ import annotations
|
|
3
3
|
import asyncio
|
4
4
|
import copy
|
5
5
|
import logging
|
6
|
+
import threading
|
6
7
|
from collections import Counter, deque
|
8
|
+
from pathlib import Path
|
7
9
|
from types import SimpleNamespace
|
8
10
|
from typing import (
|
9
11
|
Any,
|
@@ -13,7 +15,6 @@ from typing import (
|
|
13
15
|
Dict,
|
14
16
|
List,
|
15
17
|
Optional,
|
16
|
-
Set,
|
17
18
|
Tuple,
|
18
19
|
Type,
|
19
20
|
cast,
|
@@ -39,6 +40,7 @@ from langroid.parsing.routing import parse_addressed_message
|
|
39
40
|
from langroid.pydantic_v1 import BaseModel
|
40
41
|
from langroid.utils.configuration import settings
|
41
42
|
from langroid.utils.constants import (
|
43
|
+
AT, # regex for start of an addressed recipient e.g. "@"
|
42
44
|
DONE,
|
43
45
|
NO_ANSWER,
|
44
46
|
PASS,
|
@@ -47,6 +49,7 @@ from langroid.utils.constants import (
|
|
47
49
|
USER_QUIT_STRINGS,
|
48
50
|
)
|
49
51
|
from langroid.utils.logging import RichFileLogger, setup_file_logger
|
52
|
+
from langroid.utils.object_registry import scheduled_cleanup
|
50
53
|
from langroid.utils.system import hash
|
51
54
|
|
52
55
|
logger = logging.getLogger(__name__)
|
@@ -65,14 +68,18 @@ class TaskConfig(BaseModel):
|
|
65
68
|
we have config classes for `Agent`, `ChatAgent`, `LanguageModel`, etc.
|
66
69
|
|
67
70
|
Attributes:
|
68
|
-
inf_loop_cycle_len: max exact-loop cycle length: 0 => no inf loop test
|
69
|
-
inf_loop_dominance_factor: dominance factor for exact-loop detection
|
70
|
-
inf_loop_wait_factor: wait this * cycle_len msgs before loop-check
|
71
|
+
inf_loop_cycle_len (int): max exact-loop cycle length: 0 => no inf loop test
|
72
|
+
inf_loop_dominance_factor (float): dominance factor for exact-loop detection
|
73
|
+
inf_loop_wait_factor (int): wait this * cycle_len msgs before loop-check
|
74
|
+
restart_subtask_run (bool): whether to restart *every* run of this task
|
75
|
+
when run as a subtask.
|
71
76
|
"""
|
72
77
|
|
73
78
|
inf_loop_cycle_len: int = 10
|
74
79
|
inf_loop_dominance_factor: float = 1.5
|
75
80
|
inf_loop_wait_factor: int = 5
|
81
|
+
restart_as_subtask: bool = False
|
82
|
+
logs_dir: str = "logs"
|
76
83
|
|
77
84
|
|
78
85
|
class Task:
|
@@ -107,6 +114,7 @@ class Task:
|
|
107
114
|
|
108
115
|
# class variable called `cache` that is a RedisCache object
|
109
116
|
_cache: RedisCache | None = None
|
117
|
+
_background_tasks_started: bool = False
|
110
118
|
|
111
119
|
def __init__(
|
112
120
|
self,
|
@@ -119,13 +127,14 @@ class Task:
|
|
119
127
|
restart: bool = True,
|
120
128
|
default_human_response: Optional[str] = None,
|
121
129
|
interactive: bool = True,
|
122
|
-
only_user_quits_root: bool =
|
130
|
+
only_user_quits_root: bool = True,
|
123
131
|
erase_substeps: bool = False,
|
124
|
-
allow_null_result: bool =
|
132
|
+
allow_null_result: bool = False,
|
125
133
|
max_stalled_steps: int = 5,
|
126
134
|
done_if_no_response: List[Responder] = [],
|
127
135
|
done_if_response: List[Responder] = [],
|
128
136
|
config: TaskConfig = TaskConfig(),
|
137
|
+
**kwargs: Any, # catch-all for any legacy params, for backwards compatibility
|
129
138
|
):
|
130
139
|
"""
|
131
140
|
A task to be performed by an agent.
|
@@ -134,23 +143,29 @@ class Task:
|
|
134
143
|
agent (Agent): agent associated with the task
|
135
144
|
name (str): name of the task
|
136
145
|
llm_delegate (bool):
|
137
|
-
|
138
|
-
instead]
|
139
|
-
Whether to delegate control to LLM; conceptually,
|
146
|
+
Whether to delegate "control" to LLM; conceptually,
|
140
147
|
the "controlling entity" is the one "seeking" responses to its queries,
|
141
|
-
and has a goal it is aiming to achieve
|
142
|
-
either the LLM or the USER.
|
148
|
+
and has a goal it is aiming to achieve, and decides when a task is done.
|
149
|
+
The "controlling entity" is either the LLM or the USER.
|
150
|
+
(Note within a Task there is just one
|
143
151
|
LLM, and all other entities are proxies of the "User" entity).
|
152
|
+
See also: `done_if_response`, `done_if_no_response` for more granular
|
153
|
+
control of task termination.
|
144
154
|
single_round (bool):
|
145
|
-
|
146
|
-
|
147
|
-
and subsequent response by non-controller
|
148
|
-
|
155
|
+
If true, task runs until one message by "controller"
|
156
|
+
(i.e. LLM if `llm_delegate` is true, otherwise USER)
|
157
|
+
and subsequent response by non-controller [When a tool is involved,
|
158
|
+
this will not give intended results. See `done_if_response`,
|
159
|
+
`done_if_no_response` below].
|
160
|
+
termination]. If false, runs for the specified number of turns in
|
161
|
+
`run`, or until `done()` is true.
|
149
162
|
One run of step() is considered a "turn".
|
163
|
+
See also: `done_if_response`, `done_if_no_response` for more granular
|
164
|
+
control of task termination.
|
150
165
|
system_message (str): if not empty, overrides agent's system_message
|
151
166
|
user_message (str): if not empty, overrides agent's user_message
|
152
|
-
restart (bool): if true, resets the agent's message history
|
153
|
-
default_human_response (str): default response from user; useful for
|
167
|
+
restart (bool): if true, resets the agent's message history *at every run*.
|
168
|
+
default_human_response (str|None): default response from user; useful for
|
154
169
|
testing, to avoid interactive input from user.
|
155
170
|
[Instead of this, setting `interactive` usually suffices]
|
156
171
|
interactive (bool): if true, wait for human input after each non-human
|
@@ -161,18 +176,24 @@ class Task:
|
|
161
176
|
case the system will wait for a user response. In other words, use
|
162
177
|
`interactive=False` when you want a "largely non-interactive"
|
163
178
|
run, with the exception of explicit user addressing.
|
164
|
-
only_user_quits_root (bool): if true, only user can
|
165
|
-
|
166
|
-
Instead of this, setting `interactive` suffices]
|
179
|
+
only_user_quits_root (bool): if true, when interactive=True, only user can
|
180
|
+
quit the root task (Ignored when interactive=False).
|
167
181
|
erase_substeps (bool): if true, when task completes, erase intermediate
|
168
182
|
conversation with subtasks from this agent's `message_history`, and also
|
169
183
|
erase all subtask agents' `message_history`.
|
170
184
|
Note: erasing can reduce prompt sizes, but results in repetitive
|
171
185
|
sub-task delegation.
|
172
|
-
allow_null_result (bool):
|
173
|
-
If true, allow null (empty or NO_ANSWER)
|
174
|
-
|
175
|
-
Optional, default is
|
186
|
+
allow_null_result (bool):
|
187
|
+
If true, allow null (empty or NO_ANSWER) as the result of a step or
|
188
|
+
overall task result.
|
189
|
+
Optional, default is False.
|
190
|
+
*Note:* In non-interactive mode, when this is set to True,
|
191
|
+
you can have a situation where an LLM generates (non-tool) text,
|
192
|
+
and no other responders have valid responses, and a "Null result"
|
193
|
+
is inserted as a dummy response from the User entity, so the LLM
|
194
|
+
will now respond to this Null result, and this will continue
|
195
|
+
until the LLM emits a DONE signal (if instructed to do so),
|
196
|
+
otherwise it can result in an infinite loop.
|
176
197
|
max_stalled_steps (int): task considered done after this many consecutive
|
177
198
|
steps with no progress. Default is 3.
|
178
199
|
done_if_no_response (List[Responder]): consider task done if NULL
|
@@ -187,6 +208,8 @@ class Task:
|
|
187
208
|
set_parent_agent=noop_fn,
|
188
209
|
)
|
189
210
|
self.config = config
|
211
|
+
# how to behave as a sub-task; can be overriden by `add_sub_task()`
|
212
|
+
self.config_sub_task = copy.deepcopy(config)
|
190
213
|
# counts of distinct pending messages in history,
|
191
214
|
# to help detect (exact) infinite loops
|
192
215
|
self.message_counter: Counter[str] = Counter()
|
@@ -208,54 +231,51 @@ class Task:
|
|
208
231
|
the config may affect other agents using the same config.
|
209
232
|
"""
|
210
233
|
)
|
211
|
-
|
234
|
+
self.restart = restart
|
235
|
+
agent = cast(ChatAgent, agent)
|
236
|
+
self.agent: ChatAgent = agent
|
212
237
|
if isinstance(agent, ChatAgent) and len(agent.message_history) == 0 or restart:
|
213
|
-
agent
|
214
|
-
agent.
|
215
|
-
agent.clear_dialog()
|
238
|
+
self.agent.clear_history(0)
|
239
|
+
self.agent.clear_dialog()
|
216
240
|
# possibly change the system and user messages
|
217
241
|
if system_message:
|
218
242
|
# we always have at least 1 task_message
|
219
|
-
agent.set_system_message(system_message)
|
243
|
+
self.agent.set_system_message(system_message)
|
220
244
|
if user_message:
|
221
|
-
agent.set_user_message(user_message)
|
245
|
+
self.agent.set_user_message(user_message)
|
222
246
|
self.max_cost: float = 0
|
223
247
|
self.max_tokens: int = 0
|
224
248
|
self.session_id: str = ""
|
225
249
|
self.logger: None | RichFileLogger = None
|
226
250
|
self.tsv_logger: None | logging.Logger = None
|
227
251
|
self.color_log: bool = False if settings.notebook else True
|
228
|
-
|
229
|
-
self.step_progress = False # progress in current step?
|
252
|
+
|
230
253
|
self.n_stalled_steps = 0 # how many consecutive steps with no progress?
|
254
|
+
# how many 2-step-apart alternations of no_answer step-result have we had,
|
255
|
+
# i.e. x1, N/A, x2, N/A, x3, N/A ...
|
256
|
+
self.n_no_answer_alternations = 0
|
257
|
+
self._no_answer_step: int = -1
|
258
|
+
self._step_idx = -1 # current step index
|
231
259
|
self.max_stalled_steps = max_stalled_steps
|
232
260
|
self.done_if_response = [r.value for r in done_if_response]
|
233
261
|
self.done_if_no_response = [r.value for r in done_if_no_response]
|
234
262
|
self.is_done = False # is task done (based on response)?
|
235
263
|
self.is_pass_thru = False # is current response a pass-thru?
|
236
|
-
self.task_progress = False # progress in current task (since run or run_async)?
|
237
264
|
if name:
|
238
265
|
# task name overrides name in agent config
|
239
266
|
agent.config.name = name
|
240
267
|
self.name = name or agent.config.name
|
241
268
|
self.value: str = self.name
|
242
269
|
|
243
|
-
|
244
|
-
interactive = False
|
245
|
-
self.interactive = interactive
|
246
|
-
self.agent.interactive = interactive
|
247
|
-
self.message_history_idx = -1
|
248
|
-
if interactive:
|
249
|
-
only_user_quits_root = True
|
250
|
-
else:
|
251
|
-
default_human_response = default_human_response or ""
|
252
|
-
only_user_quits_root = False
|
270
|
+
self.default_human_response = default_human_response
|
253
271
|
if default_human_response is not None:
|
272
|
+
# only override agent's default_human_response if it is explicitly set
|
254
273
|
self.agent.default_human_response = default_human_response
|
255
|
-
self.
|
256
|
-
|
257
|
-
self.agent.default_human_response = None
|
274
|
+
self.interactive = interactive
|
275
|
+
self.agent.interactive = interactive
|
258
276
|
self.only_user_quits_root = only_user_quits_root
|
277
|
+
self.message_history_idx = -1
|
278
|
+
|
259
279
|
# set to True if we want to collapse multi-turn conversation with sub-tasks into
|
260
280
|
# just the first outgoing message and last incoming message.
|
261
281
|
# Note this also completely erases sub-task agents' message_history.
|
@@ -292,20 +312,18 @@ class Task:
|
|
292
312
|
self.turns = -1 # no limit
|
293
313
|
self.llm_delegate = llm_delegate
|
294
314
|
if llm_delegate:
|
295
|
-
self.controller = Entity.LLM
|
296
315
|
if self.single_round:
|
297
316
|
# 0: User instructs (delegating to LLM);
|
298
|
-
# 1: LLM asks;
|
317
|
+
# 1: LLM (as the Controller) asks;
|
299
318
|
# 2: user replies.
|
300
319
|
self.turns = 2
|
301
320
|
else:
|
302
|
-
self.controller = Entity.USER
|
303
321
|
if self.single_round:
|
304
|
-
|
305
|
-
|
322
|
+
# 0: User (as Controller) asks,
|
323
|
+
# 1: LLM replies.
|
324
|
+
self.turns = 1
|
306
325
|
# other sub_tasks this task can delegate to
|
307
326
|
self.sub_tasks: List[Task] = []
|
308
|
-
self.parent_task: Set[Task] = set()
|
309
327
|
self.caller: Task | None = None # which task called this task's `run` method
|
310
328
|
|
311
329
|
def clone(self, i: int) -> "Task":
|
@@ -321,7 +339,7 @@ class Task:
|
|
321
339
|
single_round=self.single_round,
|
322
340
|
system_message=self.agent.system_message,
|
323
341
|
user_message=self.agent.user_message,
|
324
|
-
restart=
|
342
|
+
restart=self.restart,
|
325
343
|
default_human_response=self.default_human_response,
|
326
344
|
interactive=self.interactive,
|
327
345
|
erase_substeps=self.erase_substeps,
|
@@ -338,6 +356,19 @@ class Task:
|
|
338
356
|
cls._cache = RedisCache(RedisCacheConfig(fake=False))
|
339
357
|
return cls._cache
|
340
358
|
|
359
|
+
@classmethod
|
360
|
+
def _start_background_tasks(cls) -> None:
|
361
|
+
"""Start background object registry cleanup thread. NOT USED."""
|
362
|
+
if cls._background_tasks_started:
|
363
|
+
return
|
364
|
+
cls._background_tasks_started = True
|
365
|
+
cleanup_thread = threading.Thread(
|
366
|
+
target=scheduled_cleanup,
|
367
|
+
args=(600,),
|
368
|
+
daemon=True,
|
369
|
+
)
|
370
|
+
cleanup_thread.start()
|
371
|
+
|
341
372
|
def __repr__(self) -> str:
|
342
373
|
return f"{self.name}"
|
343
374
|
|
@@ -416,24 +447,37 @@ class Task:
|
|
416
447
|
def _leave(self) -> str:
|
417
448
|
return self._indent + "<<<"
|
418
449
|
|
419
|
-
def add_sub_task(
|
450
|
+
def add_sub_task(
|
451
|
+
self,
|
452
|
+
task: (
|
453
|
+
Task | List[Task] | Tuple[Task, TaskConfig] | List[Tuple[Task, TaskConfig]]
|
454
|
+
),
|
455
|
+
) -> None:
|
420
456
|
"""
|
421
457
|
Add a sub-task (or list of subtasks) that this task can delegate
|
422
458
|
(or fail-over) to. Note that the sequence of sub-tasks is important,
|
423
459
|
since these are tried in order, as the parent task searches for a valid
|
424
|
-
response.
|
460
|
+
response (unless a sub-task is explicitly addressed).
|
425
461
|
|
426
462
|
Args:
|
427
|
-
task
|
463
|
+
task: A task, or list of tasks, or a tuple of task and task config,
|
464
|
+
or a list of tuples of task and task config.
|
465
|
+
These tasks are added as sub-tasks of the current task.
|
466
|
+
The task configs (if any) dictate how the tasks are run when
|
467
|
+
invoked as sub-tasks of other tasks. This allows users to specify
|
468
|
+
behavior applicable only in the context of a particular task-subtask
|
469
|
+
combination.
|
428
470
|
"""
|
429
|
-
|
430
471
|
if isinstance(task, list):
|
431
472
|
for t in task:
|
432
473
|
self.add_sub_task(t)
|
433
474
|
return
|
434
|
-
assert isinstance(task, Task), f"added task must be a Task, not {type(task)}"
|
435
475
|
|
436
|
-
task
|
476
|
+
if isinstance(task, tuple):
|
477
|
+
task, config = task
|
478
|
+
else:
|
479
|
+
config = TaskConfig()
|
480
|
+
task.config_sub_task = config
|
437
481
|
self.sub_tasks.append(task)
|
438
482
|
self.name_sub_task_map[task.name] = task
|
439
483
|
self.responders.append(cast(Responder, task))
|
@@ -460,30 +504,59 @@ class Task:
|
|
460
504
|
sender=Entity.USER,
|
461
505
|
),
|
462
506
|
)
|
507
|
+
elif msg is None and len(self.agent.message_history) > 1:
|
508
|
+
# if agent has a history beyond system msg, set the
|
509
|
+
# pending message to the ChatDocument linked from
|
510
|
+
# last message in the history
|
511
|
+
last_agent_msg = self.agent.message_history[-1]
|
512
|
+
self.pending_message = ChatDocument.from_id(last_agent_msg.chat_document_id)
|
513
|
+
if self.pending_message is not None:
|
514
|
+
self.pending_sender = self.pending_message.metadata.sender
|
463
515
|
else:
|
464
|
-
|
516
|
+
if isinstance(msg, ChatDocument):
|
517
|
+
# carefully deep-copy: fresh metadata.id, register
|
518
|
+
# as new obj in registry
|
519
|
+
self.pending_message = ChatDocument.deepcopy(msg)
|
465
520
|
if self.pending_message is not None and self.caller is not None:
|
466
521
|
# msg may have come from `caller`, so we pretend this is from
|
467
522
|
# the CURRENT task's USER entity
|
468
523
|
self.pending_message.metadata.sender = Entity.USER
|
524
|
+
# update parent, child, agent pointers
|
525
|
+
if msg is not None:
|
526
|
+
msg.metadata.child_id = self.pending_message.metadata.id
|
527
|
+
self.pending_message.metadata.parent_id = msg.metadata.id
|
528
|
+
self.pending_message.metadata.agent_id = self.agent.id
|
469
529
|
|
470
530
|
self._show_pending_message_if_debug()
|
471
531
|
|
472
532
|
if self.caller is not None and self.caller.logger is not None:
|
473
533
|
self.logger = self.caller.logger
|
474
534
|
else:
|
475
|
-
self.logger = RichFileLogger(
|
535
|
+
self.logger = RichFileLogger(
|
536
|
+
str(Path(self.config.logs_dir) / f"{self.name}.log"),
|
537
|
+
color=self.color_log,
|
538
|
+
)
|
476
539
|
|
477
540
|
if self.caller is not None and self.caller.tsv_logger is not None:
|
478
541
|
self.tsv_logger = self.caller.tsv_logger
|
479
542
|
else:
|
480
|
-
self.tsv_logger = setup_file_logger(
|
543
|
+
self.tsv_logger = setup_file_logger(
|
544
|
+
"tsv_logger",
|
545
|
+
str(Path(self.config.logs_dir) / f"{self.name}.tsv"),
|
546
|
+
)
|
481
547
|
header = ChatDocLoggerFields().tsv_header()
|
482
548
|
self.tsv_logger.info(f" \tTask\tResponder\t{header}")
|
483
549
|
|
484
550
|
self.log_message(Entity.USER, self.pending_message)
|
485
551
|
return self.pending_message
|
486
552
|
|
553
|
+
def reset_all_sub_tasks(self) -> None:
|
554
|
+
"""Recursively reset message history of own agent and all sub-tasks"""
|
555
|
+
self.agent.clear_history(0)
|
556
|
+
self.agent.clear_dialog()
|
557
|
+
for t in self.sub_tasks:
|
558
|
+
t.reset_all_sub_tasks()
|
559
|
+
|
487
560
|
def run(
|
488
561
|
self,
|
489
562
|
msg: Optional[str | ChatDocument] = None,
|
@@ -495,8 +568,18 @@ class Task:
|
|
495
568
|
) -> Optional[ChatDocument]:
|
496
569
|
"""Synchronous version of `run_async()`.
|
497
570
|
See `run_async()` for details."""
|
498
|
-
self.
|
571
|
+
if (self.restart and caller is None) or (
|
572
|
+
self.config_sub_task.restart_as_subtask and caller is not None
|
573
|
+
):
|
574
|
+
# We are either at top level, with restart = True, OR
|
575
|
+
# we are a sub-task with restart_as_subtask = True,
|
576
|
+
# so reset own agent and recursively for all sub-tasks
|
577
|
+
self.reset_all_sub_tasks()
|
578
|
+
|
499
579
|
self.n_stalled_steps = 0
|
580
|
+
self._no_answer_step = -1 # last step where the best explicit response was N/A
|
581
|
+
# how many N/A alternations have we had so far? (for Inf loop detection)
|
582
|
+
self.n_no_answer_alternations = 0
|
500
583
|
self.max_cost = max_cost
|
501
584
|
self.max_tokens = max_tokens
|
502
585
|
self.session_id = session_id
|
@@ -524,6 +607,7 @@ class Task:
|
|
524
607
|
turns = self.turns if turns < 0 else turns
|
525
608
|
i = 0
|
526
609
|
while True:
|
610
|
+
self._step_idx = i # used in step() below
|
527
611
|
self.step()
|
528
612
|
done, status = self.done()
|
529
613
|
if done:
|
@@ -537,7 +621,17 @@ class Task:
|
|
537
621
|
else max(turns, settings.max_turns)
|
538
622
|
)
|
539
623
|
if max_turns > 0 and i >= max_turns:
|
540
|
-
|
624
|
+
# Important to distinguish between:
|
625
|
+
# (a) intentional run for a
|
626
|
+
# fixed number of turns, where we expect the pending message
|
627
|
+
# at that stage to be the desired result, and
|
628
|
+
# (b) hitting max_turns limit, which is not intentional, and is an
|
629
|
+
# exception, resulting in a None task result
|
630
|
+
status = (
|
631
|
+
StatusCode.MAX_TURNS
|
632
|
+
if i == settings.max_turns
|
633
|
+
else StatusCode.FIXED_TURNS
|
634
|
+
)
|
541
635
|
break
|
542
636
|
if (
|
543
637
|
self.config.inf_loop_cycle_len > 0
|
@@ -553,9 +647,7 @@ class Task:
|
|
553
647
|
"""
|
554
648
|
)
|
555
649
|
|
556
|
-
final_result = self.result()
|
557
|
-
if final_result is not None:
|
558
|
-
final_result.metadata.status = status
|
650
|
+
final_result = self.result(status)
|
559
651
|
self._post_run_loop()
|
560
652
|
return final_result
|
561
653
|
|
@@ -597,8 +689,22 @@ class Task:
|
|
597
689
|
# have come from another LLM), as far as this agent is concerned, the initial
|
598
690
|
# message can be considered to be from the USER
|
599
691
|
# (from the POV of this agent's LLM).
|
600
|
-
|
692
|
+
|
693
|
+
if (
|
694
|
+
self.restart
|
695
|
+
and caller is None
|
696
|
+
or self.config_sub_task.restart_as_subtask
|
697
|
+
and caller is not None
|
698
|
+
):
|
699
|
+
# We are either at top level, with restart = True, OR
|
700
|
+
# we are a sub-task with restart_as_subtask = True,
|
701
|
+
# so reset own agent and recursively for all sub-tasks
|
702
|
+
self.reset_all_sub_tasks()
|
703
|
+
|
601
704
|
self.n_stalled_steps = 0
|
705
|
+
self._no_answer_step = -1 # last step where the best explicit response was N/A
|
706
|
+
# how many N/A alternations have we had so far? (for Inf loop detection)
|
707
|
+
self.n_no_answer_alternations = 0
|
602
708
|
self.max_cost = max_cost
|
603
709
|
self.max_tokens = max_tokens
|
604
710
|
self.session_id = session_id
|
@@ -622,6 +728,7 @@ class Task:
|
|
622
728
|
turns = self.turns if turns < 0 else turns
|
623
729
|
i = 0
|
624
730
|
while True:
|
731
|
+
self._step_idx = i # used in step() below
|
625
732
|
await self.step_async()
|
626
733
|
await asyncio.sleep(0.01) # temp yield to avoid blocking
|
627
734
|
done, status = self.done()
|
@@ -636,7 +743,17 @@ class Task:
|
|
636
743
|
else max(turns, settings.max_turns)
|
637
744
|
)
|
638
745
|
if max_turns > 0 and i >= max_turns:
|
639
|
-
|
746
|
+
# Important to distinguish between:
|
747
|
+
# (a) intentional run for a
|
748
|
+
# fixed number of turns, where we expect the pending message
|
749
|
+
# at that stage to be the desired result, and
|
750
|
+
# (b) hitting max_turns limit, which is not intentional, and is an
|
751
|
+
# exception, resulting in a None task result
|
752
|
+
status = (
|
753
|
+
StatusCode.MAX_TURNS
|
754
|
+
if i == settings.max_turns
|
755
|
+
else StatusCode.FIXED_TURNS
|
756
|
+
)
|
640
757
|
break
|
641
758
|
if (
|
642
759
|
self.config.inf_loop_cycle_len > 0
|
@@ -652,9 +769,7 @@ class Task:
|
|
652
769
|
"""
|
653
770
|
)
|
654
771
|
|
655
|
-
final_result = self.result()
|
656
|
-
if final_result is not None:
|
657
|
-
final_result.metadata.status = status
|
772
|
+
final_result = self.result(status)
|
658
773
|
self._post_run_loop()
|
659
774
|
return final_result
|
660
775
|
|
@@ -668,9 +783,6 @@ class Task:
|
|
668
783
|
self.init(msg)
|
669
784
|
# sets indentation to be printed prior to any output from agent
|
670
785
|
self.agent.indent = self._indent
|
671
|
-
if self.default_human_response is not None:
|
672
|
-
self.agent.default_human_response = self.default_human_response
|
673
|
-
|
674
786
|
self.message_history_idx = -1
|
675
787
|
if isinstance(self.agent, ChatAgent):
|
676
788
|
# mark where we are in the message history, so we can reset to this when
|
@@ -701,6 +813,23 @@ class Task:
|
|
701
813
|
if self.erase_substeps:
|
702
814
|
# TODO I don't like directly accessing agent message_history. Revisit.
|
703
815
|
# (Pchalasani)
|
816
|
+
# Note: msg history will consist of:
|
817
|
+
# - H: the original msg history, ending at idx= self.message_history_idx
|
818
|
+
# - R: this agent's response, which presumably leads to:
|
819
|
+
# - X: a series of back-and-forth msgs (including with agent's own
|
820
|
+
# responders and with sub-tasks)
|
821
|
+
# - F: the final result message, from this agent.
|
822
|
+
# Here we are deleting all of [X] from the agent's message history,
|
823
|
+
# so that it simply looks as if the sub-tasks never happened.
|
824
|
+
|
825
|
+
dropped = self.agent.message_history[
|
826
|
+
self.message_history_idx + 2 : n_messages - 1
|
827
|
+
]
|
828
|
+
# first delete the linked ChatDocuments (and descendants) from
|
829
|
+
# ObjectRegistry
|
830
|
+
for msg in dropped:
|
831
|
+
ChatDocument.delete_id(msg.chat_document_id)
|
832
|
+
# then delete the messages from the agent's message_history
|
704
833
|
del self.agent.message_history[
|
705
834
|
self.message_history_idx + 2 : n_messages - 1
|
706
835
|
]
|
@@ -727,7 +856,6 @@ class Task:
|
|
727
856
|
`step_async()`. Consider refactoring to avoid duplication.
|
728
857
|
"""
|
729
858
|
self.is_done = False
|
730
|
-
self.step_progress = False
|
731
859
|
parent = self.pending_message
|
732
860
|
recipient = (
|
733
861
|
""
|
@@ -750,9 +878,11 @@ class Task:
|
|
750
878
|
|
751
879
|
if (
|
752
880
|
Entity.USER in self.responders
|
881
|
+
and self.interactive
|
753
882
|
and not self.human_tried
|
754
883
|
and not self.agent.has_tool_message_attempt(self.pending_message)
|
755
884
|
):
|
885
|
+
# When in interactive mode,
|
756
886
|
# Give human first chance if they haven't been tried in last step,
|
757
887
|
# and the msg is not a tool-call attempt;
|
758
888
|
# This ensures human gets a chance to respond,
|
@@ -765,6 +895,8 @@ class Task:
|
|
765
895
|
responders.insert(0, Entity.USER)
|
766
896
|
|
767
897
|
found_response = False
|
898
|
+
# (responder, result) from a responder who explicitly said NO_ANSWER
|
899
|
+
no_answer_response: None | Tuple[Responder, ChatDocument] = None
|
768
900
|
for r in responders:
|
769
901
|
self.is_pass_thru = False
|
770
902
|
if not self._can_respond(r):
|
@@ -778,10 +910,14 @@ class Task:
|
|
778
910
|
recipient=recipient,
|
779
911
|
),
|
780
912
|
)
|
913
|
+
# no need to register this dummy msg in ObjectRegistry
|
914
|
+
ChatDocument.delete_id(log_doc.id())
|
781
915
|
self.log_message(r, log_doc)
|
782
916
|
continue
|
783
917
|
self.human_tried = r == Entity.USER
|
784
918
|
result = self.response(r, turns)
|
919
|
+
if result and NO_ANSWER in result.content:
|
920
|
+
no_answer_response = (r, result)
|
785
921
|
self.is_done = self._is_done_response(result, r)
|
786
922
|
self.is_pass_thru = PASS in result.content if result else False
|
787
923
|
if self.valid(result, r):
|
@@ -794,8 +930,15 @@ class Task:
|
|
794
930
|
if self.is_done:
|
795
931
|
# skip trying other responders in this step
|
796
932
|
break
|
797
|
-
if not found_response:
|
798
|
-
|
933
|
+
if not found_response: # did not find a Non-NO_ANSWER response
|
934
|
+
if no_answer_response:
|
935
|
+
# even though there was no valid response from anyone in this step,
|
936
|
+
# if there was at least one who EXPLICITLY said NO_ANSWER, then
|
937
|
+
# we process that as a valid response.
|
938
|
+
r, result = no_answer_response
|
939
|
+
self._process_valid_responder_result(r, parent, result)
|
940
|
+
else:
|
941
|
+
self._process_invalid_step_result(parent)
|
799
942
|
self._show_pending_message_if_debug()
|
800
943
|
return self.pending_message
|
801
944
|
|
@@ -821,7 +964,6 @@ class Task:
|
|
821
964
|
different context.
|
822
965
|
"""
|
823
966
|
self.is_done = False
|
824
|
-
self.step_progress = False
|
825
967
|
parent = self.pending_message
|
826
968
|
recipient = (
|
827
969
|
""
|
@@ -844,6 +986,7 @@ class Task:
|
|
844
986
|
|
845
987
|
if (
|
846
988
|
Entity.USER in self.responders
|
989
|
+
and self.interactive
|
847
990
|
and not self.human_tried
|
848
991
|
and not self.agent.has_tool_message_attempt(self.pending_message)
|
849
992
|
):
|
@@ -858,6 +1001,8 @@ class Task:
|
|
858
1001
|
responders.insert(0, Entity.USER)
|
859
1002
|
|
860
1003
|
found_response = False
|
1004
|
+
# (responder, result) from a responder who explicitly said NO_ANSWER
|
1005
|
+
no_answer_response: None | Tuple[Responder, ChatDocument] = None
|
861
1006
|
for r in responders:
|
862
1007
|
if not self._can_respond(r):
|
863
1008
|
# create dummy msg for logging
|
@@ -870,10 +1015,14 @@ class Task:
|
|
870
1015
|
recipient=recipient,
|
871
1016
|
),
|
872
1017
|
)
|
1018
|
+
# no need to register this dummy msg in ObjectRegistry
|
1019
|
+
ChatDocument.delete_id(log_doc.id())
|
873
1020
|
self.log_message(r, log_doc)
|
874
1021
|
continue
|
875
1022
|
self.human_tried = r == Entity.USER
|
876
1023
|
result = await self.response_async(r, turns)
|
1024
|
+
if result and NO_ANSWER in result.content:
|
1025
|
+
no_answer_response = (r, result)
|
877
1026
|
self.is_done = self._is_done_response(result, r)
|
878
1027
|
self.is_pass_thru = PASS in result.content if result else False
|
879
1028
|
if self.valid(result, r):
|
@@ -887,7 +1036,14 @@ class Task:
|
|
887
1036
|
# skip trying other responders in this step
|
888
1037
|
break
|
889
1038
|
if not found_response:
|
890
|
-
|
1039
|
+
if no_answer_response:
|
1040
|
+
# even though there was no valid response from anyone in this step,
|
1041
|
+
# if there was at least one who EXPLICITLY said NO_ANSWER, then
|
1042
|
+
# we process that as a valid response.
|
1043
|
+
r, result = no_answer_response
|
1044
|
+
self._process_valid_responder_result(r, parent, result)
|
1045
|
+
else:
|
1046
|
+
self._process_invalid_step_result(parent)
|
891
1047
|
self._show_pending_message_if_debug()
|
892
1048
|
return self.pending_message
|
893
1049
|
|
@@ -899,19 +1055,45 @@ class Task:
|
|
899
1055
|
) -> None:
|
900
1056
|
"""Processes valid result from a responder, during a step"""
|
901
1057
|
|
1058
|
+
# in case the valid response was a NO_ANSWER,
|
1059
|
+
if NO_ANSWER in result.content:
|
1060
|
+
if self._no_answer_step == self._step_idx - 2:
|
1061
|
+
# N/A two steps ago
|
1062
|
+
self.n_no_answer_alternations += 1
|
1063
|
+
else:
|
1064
|
+
# reset alternations counter
|
1065
|
+
self.n_no_answer_alternations = 0
|
1066
|
+
|
1067
|
+
# record the last step where the best explicit response was N/A
|
1068
|
+
self._no_answer_step = self._step_idx
|
1069
|
+
|
902
1070
|
# pending_sender is of type Responder,
|
903
1071
|
# i.e. it is either one of the agent's entities
|
904
1072
|
# OR a sub-task, that has produced a valid response.
|
905
1073
|
# Contrast this with self.pending_message.metadata.sender, which is an ENTITY
|
906
1074
|
# of this agent, or a sub-task's agent.
|
907
1075
|
if not self.is_pass_thru:
|
908
|
-
|
909
|
-
|
910
|
-
|
1076
|
+
if (
|
1077
|
+
self.pending_message is not None
|
1078
|
+
and self.pending_message.metadata.agent_id == self.agent.id
|
1079
|
+
):
|
1080
|
+
# when pending msg is from our own agent, respect the sender set there,
|
1081
|
+
# since sometimes a response may "mock" as if the response is from
|
1082
|
+
# another entity (e.g when using RewindTool, the agent handler
|
1083
|
+
# returns a result as if it were from the LLM).
|
1084
|
+
self.pending_sender = result.metadata.sender
|
1085
|
+
else:
|
1086
|
+
# when pending msg is from a sub-task, the sender is the sub-task
|
1087
|
+
self.pending_sender = r
|
911
1088
|
self.pending_message = result
|
1089
|
+
# set the parent/child links ONLY if not already set by agent internally,
|
1090
|
+
# which may happen when using the RewindTool
|
1091
|
+
if parent is not None and not result.metadata.parent_id:
|
1092
|
+
result.metadata.parent_id = parent.id()
|
1093
|
+
if parent is not None and not parent.metadata.child_id:
|
1094
|
+
parent.metadata.child_id = result.id()
|
1095
|
+
|
912
1096
|
self.log_message(self.pending_sender, result, mark=True)
|
913
|
-
self.step_progress = True
|
914
|
-
self.task_progress = True
|
915
1097
|
if self.is_pass_thru:
|
916
1098
|
self.n_stalled_steps += 1
|
917
1099
|
else:
|
@@ -933,17 +1115,20 @@ class Task:
|
|
933
1115
|
parent (ChatDocument|None): parent message of the current message
|
934
1116
|
"""
|
935
1117
|
self.n_stalled_steps += 1
|
936
|
-
if
|
937
|
-
#
|
938
|
-
# update the pending_message to a dummy NO_ANSWER msg
|
1118
|
+
if self.allow_null_result and not self.is_pass_thru:
|
1119
|
+
# Null step-result is allowed, and we're not in a "pass-thru" situation,
|
1120
|
+
# so we update the pending_message to a dummy NO_ANSWER msg
|
939
1121
|
# from the entity 'opposite' to the current pending_sender,
|
940
|
-
# so
|
1122
|
+
# so that the task can continue.
|
1123
|
+
# CAUTION: unless the LLM is instructed to signal DONE at an appropriate
|
1124
|
+
# time, this can result in an infinite loop.
|
941
1125
|
responder = (
|
942
1126
|
Entity.LLM if self.pending_sender == Entity.USER else Entity.USER
|
943
1127
|
)
|
1128
|
+
parent_id = "" if parent is None else parent.id()
|
944
1129
|
self.pending_message = ChatDocument(
|
945
1130
|
content=NO_ANSWER,
|
946
|
-
metadata=ChatDocMetaData(sender=responder,
|
1131
|
+
metadata=ChatDocMetaData(sender=responder, parent_id=parent_id),
|
947
1132
|
)
|
948
1133
|
self.pending_sender = responder
|
949
1134
|
self.log_message(self.pending_sender, self.pending_message, mark=True)
|
@@ -975,7 +1160,9 @@ class Task:
|
|
975
1160
|
max_cost=self.max_cost,
|
976
1161
|
max_tokens=self.max_tokens,
|
977
1162
|
)
|
978
|
-
result_str =
|
1163
|
+
result_str = ( # only used by callback to display content and possible tool
|
1164
|
+
"NONE" if result is None else str(ChatDocument.to_LLMMessage(result))
|
1165
|
+
)
|
979
1166
|
maybe_tool = len(extract_top_level_json(result_str)) > 0
|
980
1167
|
self.callbacks.show_subtask_response(
|
981
1168
|
task=e,
|
@@ -1063,16 +1250,23 @@ class Task:
|
|
1063
1250
|
result = await response_fn(self.pending_message)
|
1064
1251
|
return self._process_result_routing(result)
|
1065
1252
|
|
1066
|
-
def result(self) -> ChatDocument:
|
1253
|
+
def result(self, status: StatusCode | None = None) -> ChatDocument | None:
|
1067
1254
|
"""
|
1068
1255
|
Get result of task. This is the default behavior.
|
1069
1256
|
Derived classes can override this.
|
1070
1257
|
|
1071
1258
|
Note the result of a task is returned as if it is from the User entity.
|
1072
1259
|
|
1260
|
+
Args:
|
1261
|
+
status (StatusCode): status of the task when it ended
|
1073
1262
|
Returns:
|
1074
1263
|
ChatDocument: result of task
|
1075
1264
|
"""
|
1265
|
+
if status in [StatusCode.STALLED, StatusCode.MAX_TURNS, StatusCode.INF_LOOP]:
|
1266
|
+
# In these case we don't know (and don't want to try to guess)
|
1267
|
+
# what the task result should be, so we return None
|
1268
|
+
return None
|
1269
|
+
|
1076
1270
|
result_msg = self.pending_message
|
1077
1271
|
|
1078
1272
|
content = result_msg.content if result_msg else ""
|
@@ -1084,12 +1278,11 @@ class Task:
|
|
1084
1278
|
block = result_msg.metadata.block if result_msg else None
|
1085
1279
|
recipient = result_msg.metadata.recipient if result_msg else ""
|
1086
1280
|
tool_ids = result_msg.metadata.tool_ids if result_msg else []
|
1087
|
-
status = result_msg.metadata.status if result_msg else None
|
1088
1281
|
|
1089
1282
|
# regardless of which entity actually produced the result,
|
1090
1283
|
# when we return the result, we set entity to USER
|
1091
1284
|
# since to the "parent" task, this result is equivalent to a response from USER
|
1092
|
-
|
1285
|
+
result_doc = ChatDocument(
|
1093
1286
|
content=content,
|
1094
1287
|
function_call=fun_call,
|
1095
1288
|
tool_messages=tool_messages,
|
@@ -1097,12 +1290,18 @@ class Task:
|
|
1097
1290
|
source=Entity.USER,
|
1098
1291
|
sender=Entity.USER,
|
1099
1292
|
block=block,
|
1100
|
-
status=status,
|
1293
|
+
status=status or (result_msg.metadata.status if result_msg else None),
|
1101
1294
|
sender_name=self.name,
|
1102
1295
|
recipient=recipient,
|
1103
1296
|
tool_ids=tool_ids,
|
1297
|
+
parent_id=result_msg.id() if result_msg else "",
|
1298
|
+
agent_id=str(self.agent.id),
|
1104
1299
|
),
|
1105
1300
|
)
|
1301
|
+
if self.pending_message is not None:
|
1302
|
+
self.pending_message.metadata.child_id = result_doc.id()
|
1303
|
+
|
1304
|
+
return result_doc
|
1106
1305
|
|
1107
1306
|
def _is_empty_message(self, msg: str | ChatDocument | None) -> bool:
|
1108
1307
|
"""
|
@@ -1171,6 +1370,9 @@ class Task:
|
|
1171
1370
|
If the set of last (W * m) messages are the same as the
|
1172
1371
|
set of m dominant messages, then we are likely in a loop.
|
1173
1372
|
"""
|
1373
|
+
if self.n_no_answer_alternations > self.config.inf_loop_wait_factor:
|
1374
|
+
return True
|
1375
|
+
|
1174
1376
|
max_cycle_len = self.config.inf_loop_cycle_len
|
1175
1377
|
if max_cycle_len <= 0:
|
1176
1378
|
# no loop detection
|
@@ -1239,8 +1441,8 @@ class Task:
|
|
1239
1441
|
and result.content in USER_QUIT_STRINGS
|
1240
1442
|
and result.metadata.sender == Entity.USER
|
1241
1443
|
)
|
1242
|
-
if self._level == 0 and self.only_user_quits_root:
|
1243
|
-
# for top-level task, only user can quit out
|
1444
|
+
if self._level == 0 and self.interactive and self.only_user_quits_root:
|
1445
|
+
# for top-level task, in interactive mode, only user can quit out
|
1244
1446
|
return (user_quit, StatusCode.USER_QUIT if user_quit else StatusCode.OK)
|
1245
1447
|
|
1246
1448
|
if self.is_done:
|
@@ -1282,11 +1484,6 @@ class Task:
|
|
1282
1484
|
and self.caller.name != ""
|
1283
1485
|
and result.metadata.recipient == self.caller.name
|
1284
1486
|
)
|
1285
|
-
# or (
|
1286
|
-
# # Task controller is "stuck", has nothing to say
|
1287
|
-
# NO_ANSWER in result.content
|
1288
|
-
# and result.metadata.sender == self.controller
|
1289
|
-
# )
|
1290
1487
|
or user_quit
|
1291
1488
|
)
|
1292
1489
|
return (final, StatusCode.OK)
|
@@ -1468,7 +1665,6 @@ def parse_routing(
|
|
1468
1665
|
return True, addressee, None
|
1469
1666
|
else:
|
1470
1667
|
return False, addressee, content_to_send
|
1471
|
-
AT = "@"
|
1472
1668
|
if (
|
1473
1669
|
AT in content
|
1474
1670
|
and (addressee_content := parse_addressed_message(content, AT))[0] is not None
|