langroid 0.1.139__py3-none-any.whl → 0.1.219__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 +70 -0
- langroid/agent/__init__.py +22 -0
- langroid/agent/base.py +120 -33
- langroid/agent/batch.py +134 -35
- langroid/agent/callbacks/__init__.py +0 -0
- langroid/agent/callbacks/chainlit.py +608 -0
- langroid/agent/chat_agent.py +164 -100
- langroid/agent/chat_document.py +19 -2
- langroid/agent/openai_assistant.py +20 -10
- langroid/agent/special/__init__.py +33 -10
- langroid/agent/special/doc_chat_agent.py +521 -108
- langroid/agent/special/lance_doc_chat_agent.py +258 -0
- langroid/agent/special/lance_rag/__init__.py +9 -0
- langroid/agent/special/lance_rag/critic_agent.py +136 -0
- langroid/agent/special/lance_rag/lance_rag_task.py +80 -0
- langroid/agent/special/lance_rag/query_planner_agent.py +180 -0
- langroid/agent/special/lance_tools.py +44 -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 +370 -0
- langroid/agent/special/neo4j/utils/__init__.py +0 -0
- langroid/agent/special/neo4j/utils/system_message.py +46 -0
- langroid/agent/special/relevance_extractor_agent.py +23 -7
- langroid/agent/special/retriever_agent.py +29 -174
- langroid/agent/special/sql/__init__.py +7 -0
- langroid/agent/special/sql/sql_chat_agent.py +47 -23
- langroid/agent/special/sql/utils/__init__.py +11 -0
- langroid/agent/special/sql/utils/description_extractors.py +95 -46
- langroid/agent/special/sql/utils/populate_metadata.py +28 -21
- langroid/agent/special/table_chat_agent.py +43 -9
- langroid/agent/task.py +423 -114
- langroid/agent/tool_message.py +67 -10
- langroid/agent/tools/__init__.py +8 -0
- langroid/agent/tools/duckduckgo_search_tool.py +66 -0
- langroid/agent/tools/google_search_tool.py +11 -0
- langroid/agent/tools/metaphor_search_tool.py +67 -0
- langroid/agent/tools/recipient_tool.py +6 -24
- langroid/agent/tools/sciphi_search_rag_tool.py +79 -0
- langroid/cachedb/__init__.py +6 -0
- langroid/embedding_models/__init__.py +24 -0
- langroid/embedding_models/base.py +9 -1
- langroid/embedding_models/models.py +117 -17
- 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/language_models/__init__.py +22 -0
- langroid/language_models/azure_openai.py +47 -4
- langroid/language_models/base.py +26 -10
- langroid/language_models/config.py +5 -0
- langroid/language_models/openai_gpt.py +407 -121
- langroid/language_models/prompt_formatter/__init__.py +9 -0
- langroid/language_models/prompt_formatter/base.py +4 -6
- langroid/language_models/prompt_formatter/hf_formatter.py +135 -0
- langroid/language_models/utils.py +10 -9
- langroid/mytypes.py +10 -4
- langroid/parsing/__init__.py +33 -1
- langroid/parsing/document_parser.py +259 -63
- langroid/parsing/image_text.py +32 -0
- langroid/parsing/parse_json.py +143 -0
- langroid/parsing/parser.py +20 -7
- langroid/parsing/repo_loader.py +108 -46
- langroid/parsing/search.py +8 -0
- langroid/parsing/table_loader.py +44 -0
- langroid/parsing/url_loader.py +59 -13
- langroid/parsing/urls.py +18 -9
- langroid/parsing/utils.py +130 -9
- langroid/parsing/web_search.py +73 -0
- langroid/prompts/__init__.py +7 -0
- langroid/prompts/chat-gpt4-system-prompt.md +68 -0
- langroid/prompts/prompts_config.py +1 -1
- langroid/utils/__init__.py +10 -0
- langroid/utils/algorithms/__init__.py +3 -0
- langroid/utils/configuration.py +0 -1
- langroid/utils/constants.py +4 -0
- langroid/utils/logging.py +2 -5
- langroid/utils/output/__init__.py +15 -2
- langroid/utils/output/status.py +33 -0
- langroid/utils/pandas_utils.py +30 -0
- langroid/utils/pydantic_utils.py +446 -4
- langroid/utils/system.py +36 -1
- langroid/vector_store/__init__.py +34 -2
- langroid/vector_store/base.py +33 -2
- langroid/vector_store/chromadb.py +42 -13
- langroid/vector_store/lancedb.py +226 -60
- langroid/vector_store/meilisearch.py +7 -6
- langroid/vector_store/momento.py +3 -2
- langroid/vector_store/qdrantdb.py +82 -11
- {langroid-0.1.139.dist-info → langroid-0.1.219.dist-info}/METADATA +190 -129
- langroid-0.1.219.dist-info/RECORD +127 -0
- langroid/agent/special/recipient_validator_agent.py +0 -157
- langroid/parsing/json.py +0 -64
- langroid/utils/web/selenium_login.py +0 -36
- langroid-0.1.139.dist-info/RECORD +0 -103
- {langroid-0.1.139.dist-info → langroid-0.1.219.dist-info}/LICENSE +0 -0
- {langroid-0.1.139.dist-info → langroid-0.1.219.dist-info}/WHEEL +0 -0
langroid/agent/task.py
CHANGED
@@ -2,9 +2,24 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
import copy
|
4
4
|
import logging
|
5
|
-
|
5
|
+
import re
|
6
|
+
from collections import Counter
|
7
|
+
from types import SimpleNamespace
|
8
|
+
from typing import (
|
9
|
+
Any,
|
10
|
+
Callable,
|
11
|
+
Coroutine,
|
12
|
+
Dict,
|
13
|
+
List,
|
14
|
+
Optional,
|
15
|
+
Set,
|
16
|
+
Tuple,
|
17
|
+
Type,
|
18
|
+
cast,
|
19
|
+
)
|
6
20
|
|
7
21
|
from rich import print
|
22
|
+
from rich.markup import escape
|
8
23
|
|
9
24
|
from langroid.agent.base import Agent
|
10
25
|
from langroid.agent.chat_agent import ChatAgent
|
@@ -14,8 +29,9 @@ from langroid.agent.chat_document import (
|
|
14
29
|
ChatDocument,
|
15
30
|
)
|
16
31
|
from langroid.mytypes import Entity
|
32
|
+
from langroid.parsing.parse_json import extract_top_level_json
|
17
33
|
from langroid.utils.configuration import settings
|
18
|
-
from langroid.utils.constants import DONE, NO_ANSWER, USER_QUIT
|
34
|
+
from langroid.utils.constants import DONE, NO_ANSWER, PASS, PASS_TO, SEND_TO, USER_QUIT
|
19
35
|
from langroid.utils.logging import RichFileLogger, setup_file_logger
|
20
36
|
|
21
37
|
logger = logging.getLogger(__name__)
|
@@ -23,6 +39,10 @@ logger = logging.getLogger(__name__)
|
|
23
39
|
Responder = Entity | Type["Task"]
|
24
40
|
|
25
41
|
|
42
|
+
def noop_fn(*args: List[Any], **kwargs: Dict[str, Any]) -> None:
|
43
|
+
pass
|
44
|
+
|
45
|
+
|
26
46
|
class Task:
|
27
47
|
"""
|
28
48
|
A `Task` wraps an `Agent` object, and sets up the `Agent`'s goals and instructions.
|
@@ -55,7 +75,7 @@ class Task:
|
|
55
75
|
|
56
76
|
def __init__(
|
57
77
|
self,
|
58
|
-
agent: Agent,
|
78
|
+
agent: Optional[Agent] = None,
|
59
79
|
name: str = "",
|
60
80
|
llm_delegate: bool = False,
|
61
81
|
single_round: bool = False,
|
@@ -64,8 +84,12 @@ class Task:
|
|
64
84
|
restart: bool = True,
|
65
85
|
default_human_response: Optional[str] = None,
|
66
86
|
interactive: bool = True,
|
67
|
-
only_user_quits_root: bool =
|
87
|
+
only_user_quits_root: bool = False,
|
68
88
|
erase_substeps: bool = False,
|
89
|
+
allow_null_result: bool = True,
|
90
|
+
max_stalled_steps: int = 5,
|
91
|
+
done_if_no_response: List[Responder] = [],
|
92
|
+
done_if_response: List[Responder] = [],
|
69
93
|
):
|
70
94
|
"""
|
71
95
|
A task to be performed by an agent.
|
@@ -73,12 +97,17 @@ class Task:
|
|
73
97
|
Args:
|
74
98
|
agent (Agent): agent associated with the task
|
75
99
|
name (str): name of the task
|
76
|
-
llm_delegate (bool):
|
100
|
+
llm_delegate (bool):
|
101
|
+
[Deprecated, not used; use `done_if_response`, `done_if_no_response`
|
102
|
+
instead]
|
103
|
+
Whether to delegate control to LLM; conceptually,
|
77
104
|
the "controlling entity" is the one "seeking" responses to its queries,
|
78
105
|
and has a goal it is aiming to achieve. The "controlling entity" is
|
79
106
|
either the LLM or the USER. (Note within a Task there is just one
|
80
107
|
LLM, and all other entities are proxies of the "User" entity).
|
81
|
-
single_round (bool):
|
108
|
+
single_round (bool):
|
109
|
+
[Deprecated: Use `done_if_response`, `done_if_no_response` instead].
|
110
|
+
If true, task runs until one message by controller,
|
82
111
|
and subsequent response by non-controller. If false, runs for the
|
83
112
|
specified number of turns in `run`, or until `done()` is true.
|
84
113
|
One run of step() is considered a "turn".
|
@@ -87,16 +116,50 @@ class Task:
|
|
87
116
|
restart (bool): if true, resets the agent's message history
|
88
117
|
default_human_response (str): default response from user; useful for
|
89
118
|
testing, to avoid interactive input from user.
|
119
|
+
[Instead of this, setting `interactive` usually suffices]
|
90
120
|
interactive (bool): if true, wait for human input after each non-human
|
91
121
|
response (prevents infinite loop of non-human responses).
|
92
122
|
Default is true. If false, then `default_human_response` is set to ""
|
93
123
|
only_user_quits_root (bool): if true, only user can quit the root task.
|
124
|
+
[This param is ignored & deprecated; Keeping for backward compatibility.
|
125
|
+
Instead of this, setting `interactive` suffices]
|
94
126
|
erase_substeps (bool): if true, when task completes, erase intermediate
|
95
127
|
conversation with subtasks from this agent's `message_history`, and also
|
96
128
|
erase all subtask agents' `message_history`.
|
97
129
|
Note: erasing can reduce prompt sizes, but results in repetitive
|
98
130
|
sub-task delegation.
|
131
|
+
allow_null_result (bool): [Deprecated, may be removed in future.]
|
132
|
+
If true, allow null (empty or NO_ANSWER)
|
133
|
+
as the result of a step or overall task result.
|
134
|
+
Optional, default is True.
|
135
|
+
max_stalled_steps (int): task considered done after this many consecutive
|
136
|
+
steps with no progress. Default is 3.
|
137
|
+
done_if_no_response (List[Responder]): consider task done if NULL
|
138
|
+
response from any of these responders. Default is empty list.
|
139
|
+
done_if_response (List[Responder]): consider task done if NON-NULL
|
140
|
+
response from any of these responders. Default is empty list.
|
99
141
|
"""
|
142
|
+
if agent is None:
|
143
|
+
agent = ChatAgent()
|
144
|
+
|
145
|
+
self.callbacks = SimpleNamespace(
|
146
|
+
show_subtask_response=noop_fn,
|
147
|
+
set_parent_agent=noop_fn,
|
148
|
+
)
|
149
|
+
# copy the agent's config, so that we don't modify the original agent's config,
|
150
|
+
# which may be shared by other agents.
|
151
|
+
try:
|
152
|
+
config_copy = copy.deepcopy(agent.config)
|
153
|
+
agent.config = config_copy
|
154
|
+
except Exception:
|
155
|
+
logger.warning(
|
156
|
+
"""
|
157
|
+
Failed to deep-copy Agent config during task creation,
|
158
|
+
proceeding with original config. Be aware that changes to
|
159
|
+
the config may affect other agents using the same config.
|
160
|
+
"""
|
161
|
+
)
|
162
|
+
|
100
163
|
if isinstance(agent, ChatAgent) and len(agent.message_history) == 0 or restart:
|
101
164
|
agent = cast(ChatAgent, agent)
|
102
165
|
agent.clear_history(0)
|
@@ -112,20 +175,39 @@ class Task:
|
|
112
175
|
self.tsv_logger: None | logging.Logger = None
|
113
176
|
self.color_log: bool = False if settings.notebook else True
|
114
177
|
self.agent = agent
|
178
|
+
self.step_progress = False # progress in current step?
|
179
|
+
self.n_stalled_steps = 0 # how many consecutive steps with no progress?
|
180
|
+
self.max_stalled_steps = max_stalled_steps
|
181
|
+
self.done_if_response = [r.value for r in done_if_response]
|
182
|
+
self.done_if_no_response = [r.value for r in done_if_no_response]
|
183
|
+
self.is_done = False # is task done (based on response)?
|
184
|
+
self.is_pass_thru = False # is current response a pass-thru?
|
185
|
+
self.task_progress = False # progress in current task (since run or run_async)?
|
186
|
+
if name:
|
187
|
+
# task name overrides name in agent config
|
188
|
+
agent.config.name = name
|
115
189
|
self.name = name or agent.config.name
|
190
|
+
self.value: str = self.name
|
116
191
|
self.default_human_response = default_human_response
|
192
|
+
if default_human_response is not None and default_human_response == "":
|
193
|
+
interactive = False
|
117
194
|
self.interactive = interactive
|
118
195
|
self.message_history_idx = -1
|
119
|
-
if
|
120
|
-
|
196
|
+
if interactive:
|
197
|
+
only_user_quits_root = True
|
198
|
+
else:
|
199
|
+
default_human_response = default_human_response or ""
|
121
200
|
only_user_quits_root = False
|
122
201
|
if default_human_response is not None:
|
123
202
|
self.agent.default_human_response = default_human_response
|
203
|
+
if self.interactive:
|
204
|
+
self.agent.default_human_response = None
|
124
205
|
self.only_user_quits_root = only_user_quits_root
|
125
206
|
# set to True if we want to collapse multi-turn conversation with sub-tasks into
|
126
207
|
# just the first outgoing message and last incoming message.
|
127
208
|
# Note this also completely erases sub-task agents' message_history.
|
128
209
|
self.erase_substeps = erase_substeps
|
210
|
+
self.allow_null_result = allow_null_result
|
129
211
|
|
130
212
|
agent_entity_responders = agent.entity_responders()
|
131
213
|
agent_entity_responders_async = agent.entity_responders_async()
|
@@ -178,11 +260,7 @@ class Task:
|
|
178
260
|
Returns a copy of this task, with a new agent.
|
179
261
|
"""
|
180
262
|
assert isinstance(self.agent, ChatAgent), "Task clone only works for ChatAgent"
|
181
|
-
|
182
|
-
agent_cls = type(self.agent)
|
183
|
-
config_copy = copy.deepcopy(self.agent.config)
|
184
|
-
config_copy.name = f"{config_copy.name}-{i}"
|
185
|
-
agent: ChatAgent = agent_cls(config_copy)
|
263
|
+
agent: ChatAgent = self.agent.clone(i)
|
186
264
|
return Task(
|
187
265
|
agent,
|
188
266
|
name=self.name + f"-{i}",
|
@@ -193,8 +271,11 @@ class Task:
|
|
193
271
|
restart=False,
|
194
272
|
default_human_response=self.default_human_response,
|
195
273
|
interactive=self.interactive,
|
196
|
-
only_user_quits_root=self.only_user_quits_root,
|
197
274
|
erase_substeps=self.erase_substeps,
|
275
|
+
allow_null_result=self.allow_null_result,
|
276
|
+
max_stalled_steps=self.max_stalled_steps,
|
277
|
+
done_if_no_response=[Entity(s) for s in self.done_if_no_response],
|
278
|
+
done_if_response=[Entity(s) for s in self.done_if_response],
|
198
279
|
)
|
199
280
|
|
200
281
|
def __repr__(self) -> str:
|
@@ -297,7 +378,8 @@ class Task:
|
|
297
378
|
) -> Optional[ChatDocument]:
|
298
379
|
"""Synchronous version of `run_async()`.
|
299
380
|
See `run_async()` for details."""
|
300
|
-
|
381
|
+
self.task_progress = False
|
382
|
+
self.n_stalled_steps = 0
|
301
383
|
assert (
|
302
384
|
msg is None or isinstance(msg, str) or isinstance(msg, ChatDocument)
|
303
385
|
), f"msg arg in Task.run() must be None, str, or ChatDocument, not {type(msg)}"
|
@@ -361,7 +443,7 @@ class Task:
|
|
361
443
|
# have come from another LLM), as far as this agent is concerned, the initial
|
362
444
|
# message can be considered to be from the USER
|
363
445
|
# (from the POV of this agent's LLM).
|
364
|
-
|
446
|
+
self.task_progress = False
|
365
447
|
if (
|
366
448
|
isinstance(msg, ChatDocument)
|
367
449
|
and msg.metadata.recipient != ""
|
@@ -458,8 +540,11 @@ class Task:
|
|
458
540
|
def step(self, turns: int = -1) -> ChatDocument | None:
|
459
541
|
"""
|
460
542
|
Synchronous version of `step_async()`. See `step_async()` for details.
|
543
|
+
TODO: Except for the self.response() calls, this fn should be identical to
|
544
|
+
`step_async()`. Consider refactoring to avoid duplication.
|
461
545
|
"""
|
462
|
-
|
546
|
+
self.is_done = False
|
547
|
+
self.step_progress = False
|
463
548
|
parent = self.pending_message
|
464
549
|
recipient = (
|
465
550
|
""
|
@@ -475,16 +560,30 @@ class Task:
|
|
475
560
|
sender_name=Entity.AGENT,
|
476
561
|
),
|
477
562
|
)
|
478
|
-
self.
|
563
|
+
self._process_valid_responder_result(Entity.AGENT, parent, error_doc)
|
479
564
|
return error_doc
|
480
565
|
|
481
566
|
responders: List[Responder] = self.non_human_responders.copy()
|
482
|
-
|
483
|
-
|
484
|
-
|
567
|
+
|
568
|
+
if (
|
569
|
+
Entity.USER in self.responders
|
570
|
+
and not self.human_tried
|
571
|
+
and not self.agent.has_tool_message_attempt(self.pending_message)
|
572
|
+
):
|
573
|
+
# Give human first chance if they haven't been tried in last step,
|
574
|
+
# and the msg is not a tool-call attempt;
|
575
|
+
# This ensures human gets a chance to respond,
|
576
|
+
# other than to a LLM tool-call.
|
577
|
+
# When there's a tool msg attempt we want the
|
578
|
+
# Agent to be the next responder; this only makes a difference in an
|
579
|
+
# interactive setting: LLM generates tool, then we don't want user to
|
580
|
+
# have to respond, and instead let the agent_response handle the tool.
|
581
|
+
|
485
582
|
responders.insert(0, Entity.USER)
|
486
583
|
|
584
|
+
found_response = False
|
487
585
|
for r in responders:
|
586
|
+
self.is_pass_thru = False
|
488
587
|
if not self._can_respond(r):
|
489
588
|
# create dummy msg for logging
|
490
589
|
log_doc = ChatDocument(
|
@@ -500,10 +599,19 @@ class Task:
|
|
500
599
|
continue
|
501
600
|
self.human_tried = r == Entity.USER
|
502
601
|
result = self.response(r, turns)
|
503
|
-
|
504
|
-
if
|
602
|
+
self.is_done = self._is_done_response(result, r)
|
603
|
+
self.is_pass_thru = PASS in result.content if result else False
|
604
|
+
if self.valid(result, r):
|
605
|
+
found_response = True
|
606
|
+
assert result is not None
|
607
|
+
self._process_valid_responder_result(r, parent, result)
|
505
608
|
break
|
506
|
-
|
609
|
+
else:
|
610
|
+
self.log_message(r, result)
|
611
|
+
if self.is_done:
|
612
|
+
# skip trying other responders in this step
|
613
|
+
break
|
614
|
+
if not found_response:
|
507
615
|
self._process_invalid_step_result(parent)
|
508
616
|
self._show_pending_message_if_debug()
|
509
617
|
return self.pending_message
|
@@ -529,7 +637,8 @@ class Task:
|
|
529
637
|
other use-cases, e.g. where we want to run a task step by step in a
|
530
638
|
different context.
|
531
639
|
"""
|
532
|
-
|
640
|
+
self.is_done = False
|
641
|
+
self.step_progress = False
|
533
642
|
parent = self.pending_message
|
534
643
|
recipient = (
|
535
644
|
""
|
@@ -545,15 +654,27 @@ class Task:
|
|
545
654
|
sender_name=Entity.AGENT,
|
546
655
|
),
|
547
656
|
)
|
548
|
-
self.
|
657
|
+
self._process_valid_responder_result(Entity.AGENT, parent, error_doc)
|
549
658
|
return error_doc
|
550
659
|
|
551
660
|
responders: List[Responder] = self.non_human_responders_async.copy()
|
552
|
-
|
553
|
-
|
554
|
-
|
661
|
+
|
662
|
+
if (
|
663
|
+
Entity.USER in self.responders
|
664
|
+
and not self.human_tried
|
665
|
+
and not self.agent.has_tool_message_attempt(self.pending_message)
|
666
|
+
):
|
667
|
+
# Give human first chance if they haven't been tried in last step,
|
668
|
+
# and the msg is not a tool-call attempt;
|
669
|
+
# This ensures human gets a chance to respond,
|
670
|
+
# other than to a LLM tool-call.
|
671
|
+
# When there's a tool msg attempt we want the
|
672
|
+
# Agent to be the next responder; this only makes a difference in an
|
673
|
+
# interactive setting: LLM generates tool, then we don't want user to
|
674
|
+
# have to respond, and instead let the agent_response handle the tool.
|
555
675
|
responders.insert(0, Entity.USER)
|
556
676
|
|
677
|
+
found_response = False
|
557
678
|
for r in responders:
|
558
679
|
if not self._can_respond(r):
|
559
680
|
# create dummy msg for logging
|
@@ -570,69 +691,117 @@ class Task:
|
|
570
691
|
continue
|
571
692
|
self.human_tried = r == Entity.USER
|
572
693
|
result = await self.response_async(r, turns)
|
573
|
-
|
574
|
-
if
|
694
|
+
self.is_done = self._is_done_response(result, r)
|
695
|
+
self.is_pass_thru = PASS in result.content if result else False
|
696
|
+
if self.valid(result, r):
|
697
|
+
found_response = True
|
698
|
+
assert result is not None
|
699
|
+
self._process_valid_responder_result(r, parent, result)
|
700
|
+
break
|
701
|
+
else:
|
702
|
+
self.log_message(r, result)
|
703
|
+
if self.is_done:
|
704
|
+
# skip trying other responders in this step
|
575
705
|
break
|
576
|
-
if not
|
706
|
+
if not found_response:
|
577
707
|
self._process_invalid_step_result(parent)
|
578
708
|
self._show_pending_message_if_debug()
|
579
709
|
return self.pending_message
|
580
710
|
|
581
|
-
def
|
711
|
+
def _process_valid_responder_result(
|
582
712
|
self,
|
583
713
|
r: Responder,
|
584
714
|
parent: ChatDocument | None,
|
585
|
-
result: ChatDocument
|
586
|
-
) ->
|
587
|
-
"""Processes result
|
588
|
-
|
589
|
-
|
590
|
-
|
591
|
-
|
715
|
+
result: ChatDocument,
|
716
|
+
) -> None:
|
717
|
+
"""Processes valid result from a responder, during a step"""
|
718
|
+
|
719
|
+
# pending_sender is of type Responder,
|
720
|
+
# i.e. it is either one of the agent's entities
|
721
|
+
# OR a sub-task, that has produced a valid response.
|
722
|
+
# Contrast this with self.pending_message.metadata.sender, which is an ENTITY
|
723
|
+
# of this agent, or a sub-task's agent.
|
724
|
+
if not self.is_pass_thru:
|
592
725
|
self.pending_sender = r
|
593
|
-
|
594
|
-
|
595
|
-
):
|
596
|
-
# This code is only used by the now-deprecated RecipientValidatorAgent.
|
597
|
-
# (which has been deprecated in favor of using the RecipientTool).
|
598
|
-
# When result is from a sub-task, and `result.metadata` contains
|
599
|
-
# a non-null `parent_responder`, pretend this result was
|
600
|
-
# from the parent_responder, by setting `self.pending_sender`.
|
601
|
-
self.pending_sender = result.metadata.parent_responder
|
602
|
-
# Since we've just used the "pretend responder",
|
603
|
-
# clear out the pretend responder in metadata
|
604
|
-
# (so that it doesn't get used again)
|
605
|
-
result.metadata.parent_responder = None
|
606
|
-
result.metadata.parent = parent
|
607
|
-
old_attachment = (
|
608
|
-
self.pending_message.attachment if self.pending_message else None
|
609
|
-
)
|
726
|
+
result.metadata.parent = parent
|
727
|
+
if not self.is_pass_thru:
|
610
728
|
self.pending_message = result
|
611
|
-
|
612
|
-
|
613
|
-
|
614
|
-
|
615
|
-
|
729
|
+
self.log_message(self.pending_sender, result, mark=True)
|
730
|
+
self.step_progress = True
|
731
|
+
self.task_progress = True
|
732
|
+
if self.is_pass_thru:
|
733
|
+
self.n_stalled_steps += 1
|
616
734
|
else:
|
617
|
-
|
618
|
-
|
735
|
+
# reset stuck counter since we made progress
|
736
|
+
self.n_stalled_steps = 0
|
619
737
|
|
620
738
|
def _process_invalid_step_result(self, parent: ChatDocument | None) -> None:
|
621
|
-
|
622
|
-
|
623
|
-
|
624
|
-
|
625
|
-
|
626
|
-
|
739
|
+
"""
|
740
|
+
Since step had no valid result from any responder, decide whether to update the
|
741
|
+
self.pending_message to a NO_ANSWER message from the opposite entity,
|
742
|
+
or leave it as is.
|
743
|
+
Args:
|
744
|
+
parent (ChatDocument|None): parent message of the current message
|
745
|
+
"""
|
746
|
+
self.n_stalled_steps += 1
|
747
|
+
if (not self.task_progress or self.allow_null_result) and not self.is_pass_thru:
|
748
|
+
# There has been no progress at all in this task, so we
|
749
|
+
# update the pending_message to a dummy NO_ANSWER msg
|
750
|
+
# from the entity 'opposite' to the current pending_sender,
|
751
|
+
# so we show "progress" and avoid getting stuck in an infinite loop.
|
752
|
+
responder = (
|
753
|
+
Entity.LLM if self.pending_sender == Entity.USER else Entity.USER
|
754
|
+
)
|
755
|
+
self.pending_message = ChatDocument(
|
756
|
+
content=NO_ANSWER,
|
757
|
+
metadata=ChatDocMetaData(sender=responder, parent=parent),
|
758
|
+
)
|
759
|
+
self.pending_sender = responder
|
627
760
|
self.log_message(self.pending_sender, self.pending_message, mark=True)
|
628
761
|
|
629
762
|
def _show_pending_message_if_debug(self) -> None:
|
630
763
|
if self.pending_message is None:
|
631
764
|
return
|
632
765
|
if settings.debug:
|
633
|
-
sender_str = str(self.pending_sender)
|
634
|
-
msg_str = str(self.pending_message)
|
635
|
-
print(f"[
|
766
|
+
sender_str = escape(str(self.pending_sender))
|
767
|
+
msg_str = escape(str(self.pending_message))
|
768
|
+
print(f"[grey37][{sender_str}]{msg_str}[/grey37]")
|
769
|
+
|
770
|
+
def _parse_routing(self, msg: ChatDocument | str) -> Tuple[bool | None, str | None]:
|
771
|
+
"""
|
772
|
+
Parse routing instruction if any, of the form:
|
773
|
+
PASS:<recipient> (pass current pending msg to recipient)
|
774
|
+
SEND:<recipient> <content> (send content to recipient)
|
775
|
+
Args:
|
776
|
+
msg (ChatDocument|str|None): message to parse
|
777
|
+
Returns:
|
778
|
+
Tuple[bool,str|None]:
|
779
|
+
bool: true=PASS, false=SEND, or None if neither
|
780
|
+
str: recipient, or None
|
781
|
+
"""
|
782
|
+
# handle routing instruction in result if any,
|
783
|
+
# of the form PASS=<recipient>
|
784
|
+
content = msg.content if isinstance(msg, ChatDocument) else msg
|
785
|
+
content = content.strip()
|
786
|
+
if PASS in content and PASS_TO not in content:
|
787
|
+
return True, None
|
788
|
+
if PASS_TO in content and content.split(":")[1] != "":
|
789
|
+
return True, content.split(":")[1]
|
790
|
+
if SEND_TO in content and (send_parts := re.split(r"[,: ]", content))[1] != "":
|
791
|
+
# assume syntax is SEND_TO:<recipient> <content>
|
792
|
+
# or SEND_TO:<recipient>,<content> or SEND_TO:<recipient>:<content>
|
793
|
+
recipient = send_parts[1].strip()
|
794
|
+
# get content to send, clean out routing instruction, and
|
795
|
+
# start from 1 char after SEND_TO:<recipient>,
|
796
|
+
# because we expect there is either a blank or some other separator
|
797
|
+
# after the recipient
|
798
|
+
content_to_send = content.replace(f"{SEND_TO}{recipient}", "").strip()[1:]
|
799
|
+
# if no content then treat same as PASS_TO
|
800
|
+
if content_to_send == "":
|
801
|
+
return True, recipient
|
802
|
+
else:
|
803
|
+
return False, recipient
|
804
|
+
return None, None
|
636
805
|
|
637
806
|
def response(
|
638
807
|
self,
|
@@ -644,15 +813,57 @@ class Task:
|
|
644
813
|
"""
|
645
814
|
if isinstance(e, Task):
|
646
815
|
actual_turns = e.turns if e.turns > 0 else turns
|
816
|
+
e.agent.callbacks.set_parent_agent(self.agent)
|
817
|
+
# e.callbacks.set_parent_agent(self.agent)
|
647
818
|
result = e.run(
|
648
819
|
self.pending_message,
|
649
820
|
turns=actual_turns,
|
650
821
|
caller=self,
|
651
822
|
)
|
652
|
-
|
823
|
+
result_str = str(ChatDocument.to_LLMMessage(result))
|
824
|
+
maybe_tool = len(extract_top_level_json(result_str)) > 0
|
825
|
+
self.callbacks.show_subtask_response(
|
826
|
+
task=e,
|
827
|
+
content=result_str,
|
828
|
+
is_tool=maybe_tool,
|
829
|
+
)
|
653
830
|
else:
|
654
831
|
response_fn = self._entity_responder_map[cast(Entity, e)]
|
655
832
|
result = response_fn(self.pending_message)
|
833
|
+
return self._process_result_routing(result)
|
834
|
+
|
835
|
+
def _process_result_routing(
|
836
|
+
self, result: ChatDocument | None
|
837
|
+
) -> ChatDocument | None:
|
838
|
+
# process result in case there is a routing instruction
|
839
|
+
if result is None:
|
840
|
+
return None
|
841
|
+
is_pass, recipient = self._parse_routing(result)
|
842
|
+
if is_pass is None: # no routing, i.e. neither PASS nor SEND
|
843
|
+
return result
|
844
|
+
if is_pass:
|
845
|
+
if recipient is None or self.pending_message is None:
|
846
|
+
# Just PASS, no recipient
|
847
|
+
# This means pass on self.pending_message to the next responder
|
848
|
+
# in the default sequence of responders.
|
849
|
+
# So leave result intact since we handle "PASS" in step()
|
850
|
+
return result
|
851
|
+
# set recipient in self.pending_message
|
852
|
+
self.pending_message.metadata.recipient = recipient
|
853
|
+
# clear out recipient, replace with just PASS
|
854
|
+
result.content = result.content.replace(
|
855
|
+
f"{PASS_TO}:{recipient}", PASS
|
856
|
+
).strip()
|
857
|
+
return result
|
858
|
+
elif recipient is not None:
|
859
|
+
# we are sending non-empty content to non-null recipient
|
860
|
+
# clean up result.content, set metadata.recipient and return
|
861
|
+
result.content = result.content.replace(
|
862
|
+
f"{SEND_TO}:{recipient}", ""
|
863
|
+
).strip()
|
864
|
+
result.metadata.recipient = recipient
|
865
|
+
return result
|
866
|
+
else:
|
656
867
|
return result
|
657
868
|
|
658
869
|
async def response_async(
|
@@ -677,16 +888,24 @@ class Task:
|
|
677
888
|
"""
|
678
889
|
if isinstance(e, Task):
|
679
890
|
actual_turns = e.turns if e.turns > 0 else turns
|
891
|
+
e.agent.callbacks.set_parent_agent(self.agent)
|
892
|
+
# e.callbacks.set_parent_agent(self.agent)
|
680
893
|
result = await e.run_async(
|
681
894
|
self.pending_message,
|
682
895
|
turns=actual_turns,
|
683
896
|
caller=self,
|
684
897
|
)
|
685
|
-
|
898
|
+
result_str = str(ChatDocument.to_LLMMessage(result))
|
899
|
+
maybe_tool = len(extract_top_level_json(result_str)) > 0
|
900
|
+
self.callbacks.show_subtask_response(
|
901
|
+
task=e,
|
902
|
+
content=result_str,
|
903
|
+
is_tool=maybe_tool,
|
904
|
+
)
|
686
905
|
else:
|
687
906
|
response_fn = self._entity_responder_async_map[cast(Entity, e)]
|
688
907
|
result = await response_fn(self.pending_message)
|
689
|
-
|
908
|
+
return self._process_result_routing(result)
|
690
909
|
|
691
910
|
def result(self) -> ChatDocument:
|
692
911
|
"""
|
@@ -702,7 +921,7 @@ class Task:
|
|
702
921
|
# assuming it is of the form "DONE: <content>"
|
703
922
|
content = content.replace(DONE, "").strip()
|
704
923
|
fun_call = result_msg.function_call if result_msg else None
|
705
|
-
|
924
|
+
tool_messages = result_msg.tool_messages if result_msg else []
|
706
925
|
block = result_msg.metadata.block if result_msg else None
|
707
926
|
recipient = result_msg.metadata.recipient if result_msg else None
|
708
927
|
responder = result_msg.metadata.parent_responder if result_msg else None
|
@@ -714,7 +933,7 @@ class Task:
|
|
714
933
|
return ChatDocument(
|
715
934
|
content=content,
|
716
935
|
function_call=fun_call,
|
717
|
-
|
936
|
+
tool_messages=tool_messages,
|
718
937
|
metadata=ChatDocMetaData(
|
719
938
|
source=Entity.USER,
|
720
939
|
sender=Entity.USER,
|
@@ -726,57 +945,152 @@ class Task:
|
|
726
945
|
),
|
727
946
|
)
|
728
947
|
|
729
|
-
def
|
948
|
+
def _is_empty_message(self, msg: str | ChatDocument | None) -> bool:
|
949
|
+
"""
|
950
|
+
Check if msg is empty or None
|
951
|
+
Args:
|
952
|
+
msg (str|ChatDocument|None): message to check
|
953
|
+
Returns:
|
954
|
+
bool: True if msg is (equivalent to) empty or None, False otherwise
|
955
|
+
"""
|
956
|
+
return (
|
957
|
+
msg is None
|
958
|
+
or (isinstance(msg, str) and msg.strip() in [PASS, ""])
|
959
|
+
or (
|
960
|
+
isinstance(msg, ChatDocument)
|
961
|
+
and msg.content.strip() in [PASS, ""]
|
962
|
+
and msg.function_call is None
|
963
|
+
and msg.tool_messages == []
|
964
|
+
)
|
965
|
+
)
|
966
|
+
|
967
|
+
def _is_done_response(
|
968
|
+
self, result: str | None | ChatDocument, responder: Responder
|
969
|
+
) -> bool:
|
970
|
+
"""Is the task done based on the response from the given responder?"""
|
971
|
+
|
972
|
+
response_says_done = result is not None and (
|
973
|
+
(isinstance(result, str) and DONE in result)
|
974
|
+
or (isinstance(result, ChatDocument) and DONE in result.content)
|
975
|
+
)
|
976
|
+
return (
|
977
|
+
(
|
978
|
+
responder.value in self.done_if_response
|
979
|
+
and not self._is_empty_message(result)
|
980
|
+
)
|
981
|
+
or (
|
982
|
+
responder.value in self.done_if_no_response
|
983
|
+
and self._is_empty_message(result)
|
984
|
+
)
|
985
|
+
or (not self._is_empty_message(result) and response_says_done)
|
986
|
+
)
|
987
|
+
|
988
|
+
def _maybe_infinite_loop(self, history: int = 10) -> bool:
|
989
|
+
"""
|
990
|
+
TODO Not currently used, until we figure out best way.
|
991
|
+
Check if {NO_ANSWER}, empty answer, or a specific non-LLM msg occurs too
|
992
|
+
often in history of pending messages -- this can be an indicator of a possible
|
993
|
+
multi-step infinite loop that we should exit.
|
994
|
+
(A single-step infinite loop is where individual steps don't show progress
|
995
|
+
and are easy to detect via n_stalled_steps, but a multi-step infinite loop
|
996
|
+
could show "progress" at each step, but can still be an infinite loop, e.g.
|
997
|
+
if the steps are just alternating between two messages).
|
998
|
+
"""
|
999
|
+
p = self.pending_message
|
1000
|
+
n_no_answers = 0
|
1001
|
+
n_empty_answers = 0
|
1002
|
+
counter: Counter[str] = Counter()
|
1003
|
+
# count number of NO_ANSWER and empty answers in last up to 10 messages
|
1004
|
+
# in ancestors of self.pending_message
|
1005
|
+
for _ in range(history):
|
1006
|
+
if p is None:
|
1007
|
+
break
|
1008
|
+
n_no_answers += p.content.strip() == NO_ANSWER
|
1009
|
+
n_empty_answers += p.content.strip() == "" and p.function_call is None
|
1010
|
+
if p.metadata.sender != Entity.LLM and PASS not in p.content:
|
1011
|
+
counter.update([p.metadata.sender + ":" + p.content])
|
1012
|
+
p = p.metadata.parent
|
1013
|
+
|
1014
|
+
# freq of most common message in history
|
1015
|
+
high_freq = (counter.most_common(1) or [("", 0)])[0][1]
|
1016
|
+
# We deem this a potential infinite loop if:
|
1017
|
+
# - a specific non-LLM msg occurs too often, or
|
1018
|
+
# - a NO_ANSWER or empty answer occurs too often
|
1019
|
+
return max(high_freq, n_no_answers) > self.max_stalled_steps
|
1020
|
+
|
1021
|
+
def done(
|
1022
|
+
self, result: ChatDocument | None = None, r: Responder | None = None
|
1023
|
+
) -> bool:
|
730
1024
|
"""
|
731
1025
|
Check if task is done. This is the default behavior.
|
732
1026
|
Derived classes can override this.
|
1027
|
+
Args:
|
1028
|
+
result (ChatDocument|None): result from a responder
|
1029
|
+
r (Responder|None): responder that produced the result
|
1030
|
+
Not used here, but could be used by derived classes.
|
733
1031
|
Returns:
|
734
1032
|
bool: True if task is done, False otherwise
|
735
1033
|
"""
|
1034
|
+
result = result or self.pending_message
|
736
1035
|
user_quit = (
|
737
|
-
|
738
|
-
and
|
739
|
-
and
|
1036
|
+
result is not None
|
1037
|
+
and result.content in USER_QUIT
|
1038
|
+
and result.metadata.sender == Entity.USER
|
740
1039
|
)
|
741
1040
|
if self._level == 0 and self.only_user_quits_root:
|
742
1041
|
# for top-level task, only user can quit out
|
743
1042
|
return user_quit
|
744
1043
|
|
1044
|
+
if self.is_done:
|
1045
|
+
return True
|
1046
|
+
|
1047
|
+
if self.n_stalled_steps >= self.max_stalled_steps:
|
1048
|
+
# we are stuck, so bail to avoid infinite loop
|
1049
|
+
logger.warning(
|
1050
|
+
f"Task {self.name} stuck for {self.max_stalled_steps} steps; exiting."
|
1051
|
+
)
|
1052
|
+
return True
|
1053
|
+
|
745
1054
|
return (
|
746
1055
|
# no valid response from any entity/agent in current turn
|
747
|
-
|
748
|
-
#
|
749
|
-
or DONE in
|
1056
|
+
result is None
|
1057
|
+
# An entity decided task is done
|
1058
|
+
or DONE in result.content
|
750
1059
|
or ( # current task is addressing message to caller task
|
751
1060
|
self.caller is not None
|
752
1061
|
and self.caller.name != ""
|
753
|
-
and
|
754
|
-
)
|
755
|
-
or (
|
756
|
-
# Task controller is "stuck", has nothing to say
|
757
|
-
NO_ANSWER in self.pending_message.content
|
758
|
-
and self.pending_message.metadata.sender == self.controller
|
1062
|
+
and result.metadata.recipient == self.caller.name
|
759
1063
|
)
|
1064
|
+
# or (
|
1065
|
+
# # Task controller is "stuck", has nothing to say
|
1066
|
+
# NO_ANSWER in result.content
|
1067
|
+
# and result.metadata.sender == self.controller
|
1068
|
+
# )
|
760
1069
|
or user_quit
|
761
1070
|
)
|
762
1071
|
|
763
|
-
def valid(
|
1072
|
+
def valid(
|
1073
|
+
self,
|
1074
|
+
result: Optional[ChatDocument],
|
1075
|
+
r: Responder,
|
1076
|
+
) -> bool:
|
764
1077
|
"""
|
765
|
-
Is the result from an entity or sub-task
|
766
|
-
for responses
|
1078
|
+
Is the result from a Responder (i.e. an entity or sub-task)
|
1079
|
+
such that we can stop searching for responses in this step?
|
767
1080
|
"""
|
768
1081
|
# TODO caution we should ensure that no handler method (tool) returns simply
|
769
1082
|
# an empty string (e.g when showing contents of an empty file), since that
|
770
1083
|
# would be considered an invalid response, and other responders will wrongly
|
771
1084
|
# be given a chance to respond.
|
1085
|
+
|
1086
|
+
# if task would be considered done given responder r's `result`,
|
1087
|
+
# then consider the result valid.
|
1088
|
+
if result is not None and self.done(result, r):
|
1089
|
+
return True
|
772
1090
|
return (
|
773
1091
|
result is not None
|
774
|
-
and (result
|
775
|
-
and (
|
776
|
-
# controller is stuck and we are done with task loop
|
777
|
-
NO_ANSWER not in result.content
|
778
|
-
or result.metadata.sender == self.controller
|
779
|
-
)
|
1092
|
+
and not self._is_empty_message(result)
|
1093
|
+
and result.content.strip() != NO_ANSWER
|
780
1094
|
)
|
781
1095
|
|
782
1096
|
def log_message(
|
@@ -853,28 +1167,23 @@ class Task:
|
|
853
1167
|
"""
|
854
1168
|
Is the recipient explicitly specified and does not match responder "e" ?
|
855
1169
|
"""
|
856
|
-
|
857
|
-
|
858
|
-
|
859
|
-
|
860
|
-
|
861
|
-
|
862
|
-
# or the name of another task.
|
863
|
-
return recipient not in (e.name, self.name)
|
1170
|
+
# Note that recipient could be specified as an Entity or a Task name
|
1171
|
+
return (
|
1172
|
+
self.pending_message is not None
|
1173
|
+
and (recipient := self.pending_message.metadata.recipient) != ""
|
1174
|
+
and recipient not in (e.name, self.name)
|
1175
|
+
)
|
864
1176
|
|
865
1177
|
def _can_respond(self, e: Responder) -> bool:
|
866
1178
|
if self.pending_sender == e:
|
1179
|
+
# Responder cannot respond to its own message
|
867
1180
|
return False
|
868
1181
|
if self.pending_message is None:
|
869
1182
|
return True
|
870
|
-
if self.pending_message.metadata.block == e:
|
871
|
-
# the entity should only be blocked at the first try;
|
872
|
-
# Remove the block so it does not block the entity forever
|
873
|
-
self.pending_message.metadata.block = None
|
874
|
-
return False
|
875
1183
|
if self._recipient_mismatch(e):
|
1184
|
+
# Cannot respond if not addressed to this entity
|
876
1185
|
return False
|
877
|
-
return self.pending_message
|
1186
|
+
return self.pending_message.metadata.block != e
|
878
1187
|
|
879
1188
|
def set_color_log(self, enable: bool = True) -> None:
|
880
1189
|
"""
|