langroid 0.8.0__py3-none-any.whl → 0.9.0__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.
@@ -47,6 +47,8 @@ class ChatAgentConfig(AgentConfig):
47
47
  the OpenAI tool-call API is used, rather than the older/deprecated
48
48
  function-call API. However the tool-call API has some tricky aspects,
49
49
  hence we set this to False by default.
50
+ enable_orchestration_tool_handling: whether to enable handling of orchestration
51
+ tools, e.g. ForwardTool, DoneTool, PassTool, etc.
50
52
  """
51
53
 
52
54
  system_message: str = "You are a helpful assistant."
@@ -54,6 +56,7 @@ class ChatAgentConfig(AgentConfig):
54
56
  use_tools: bool = False
55
57
  use_functions_api: bool = True
56
58
  use_tools_api: bool = False
59
+ enable_orchestration_tool_handling: bool = True
57
60
 
58
61
  def _set_fn_or_tools(self, fn_available: bool) -> None:
59
62
  """
@@ -145,6 +148,30 @@ class ChatAgent(Agent):
145
148
  self.llm_functions_usable: Set[str] = set()
146
149
  self.llm_function_force: Optional[Dict[str, str]] = None
147
150
 
151
+ if self.config.enable_orchestration_tool_handling:
152
+ # Only enable HANDLING by `agent_response`, NOT LLM generation of these.
153
+ # This is useful where tool-handlers or agent_response generate these
154
+ # tools, and need to be handled.
155
+ # We don't want enable orch tool GENERATION by default, since that
156
+ # might clutter-up the LLM system message unnecessarily.
157
+ from langroid.agent.tools.orchestration import (
158
+ AgentDoneTool,
159
+ AgentSendTool,
160
+ DonePassTool,
161
+ DoneTool,
162
+ ForwardTool,
163
+ PassTool,
164
+ SendTool,
165
+ )
166
+
167
+ self.enable_message(ForwardTool, use=False, handle=True)
168
+ self.enable_message(DoneTool, use=False, handle=True)
169
+ self.enable_message(AgentDoneTool, use=False, handle=True)
170
+ self.enable_message(PassTool, use=False, handle=True)
171
+ self.enable_message(DonePassTool, use=False, handle=True)
172
+ self.enable_message(SendTool, use=False, handle=True)
173
+ self.enable_message(AgentSendTool, use=False, handle=True)
174
+
148
175
  @staticmethod
149
176
  def from_id(id: str) -> "ChatAgent":
150
177
  """
@@ -272,19 +299,25 @@ class ChatAgent(Agent):
272
299
  Returns:
273
300
  str: formatting rules
274
301
  """
275
- enabled_classes: List[Type[ToolMessage]] = list(self.llm_tools_map.values())
276
- if len(enabled_classes) == 0:
302
+ # ONLY Usable tools (i.e. LLM-generation allowed),
303
+ usable_tool_classes: List[Type[ToolMessage]] = [
304
+ t
305
+ for t in list(self.llm_tools_map.values())
306
+ if not t._handle_only
307
+ and t.default_value("request") in self.llm_tools_usable
308
+ ]
309
+
310
+ if len(usable_tool_classes) == 0:
277
311
  return "You can ask questions in natural language."
278
312
  json_instructions = "\n\n".join(
279
313
  [
280
314
  msg_cls.json_instructions(tool=self.config.use_tools)
281
- for _, msg_cls in enumerate(enabled_classes)
282
- if msg_cls.default_value("request") in self.llm_tools_usable
315
+ for msg_cls in usable_tool_classes
283
316
  ]
284
317
  )
285
318
  # if any of the enabled classes has json_group_instructions, then use that,
286
319
  # else fall back to ToolMessage.json_group_instructions
287
- for msg_cls in enabled_classes:
320
+ for msg_cls in usable_tool_classes:
288
321
  if hasattr(msg_cls, "json_group_instructions") and callable(
289
322
  getattr(msg_cls, "json_group_instructions")
290
323
  ):
@@ -420,9 +453,16 @@ class ChatAgent(Agent):
420
453
  # remove leading and trailing newlines and other whitespace
421
454
  return LLMMessage(role=Role.SYSTEM, content=content.strip())
422
455
 
456
+ def unhanded_tools(self) -> set[str]:
457
+ """The set of tools that are known but not handled.
458
+ Useful in task flow: an agent can refuse to accept an incoming msg
459
+ when it only has unhandled tools.
460
+ """
461
+ return self.llm_tools_known - self.llm_tools_handled
462
+
423
463
  def enable_message(
424
464
  self,
425
- message_class: Optional[Type[ToolMessage]],
465
+ message_class: Optional[Type[ToolMessage] | List[Type[ToolMessage]]],
426
466
  use: bool = True,
427
467
  handle: bool = True,
428
468
  force: bool = False,
@@ -435,8 +475,10 @@ class ChatAgent(Agent):
435
475
  - tool HANDLING (i.e. the agent can handle JSON from this tool),
436
476
 
437
477
  Args:
438
- message_class: The ToolMessage class to enable,
478
+ message_class: The ToolMessage class OR List of such classes to enable,
439
479
  for USE, or HANDLING, or both.
480
+ If this is a list of ToolMessage classes, then the remain args are
481
+ applied to all classes.
440
482
  Optional; if None, then apply the enabling to all tools in the
441
483
  agent's toolset that have been enabled so far.
442
484
  use: IF True, allow the agent (LLM) to use this tool (or all tools),
@@ -448,12 +490,23 @@ class ChatAgent(Agent):
448
490
  `force` is ignored if `message_class` is None.
449
491
  require_recipient: whether to require that recipient be specified
450
492
  when using the tool message (only applies if `use` is True).
451
- require_defaults: whether to include fields that have default values,
493
+ include_defaults: whether to include fields that have default values,
452
494
  in the "properties" section of the JSON format instructions.
453
495
  (Normally the OpenAI completion API ignores these fields,
454
496
  but the Assistant fn-calling seems to pay attn to these,
455
497
  and if we don't want this, we should set this to False.)
456
498
  """
499
+ if message_class is not None and isinstance(message_class, list):
500
+ for mc in message_class:
501
+ self.enable_message(
502
+ mc,
503
+ use=use,
504
+ handle=handle,
505
+ force=force,
506
+ require_recipient=require_recipient,
507
+ include_defaults=include_defaults,
508
+ )
509
+ return None
457
510
  if require_recipient and message_class is not None:
458
511
  message_class = message_class.require_recipient()
459
512
  super().enable_message_handling(message_class) # enables handling only
@@ -468,6 +521,8 @@ class ChatAgent(Agent):
468
521
  self.llm_function_force = None
469
522
 
470
523
  for t in tools:
524
+ self.llm_tools_known.add(t)
525
+
471
526
  if handle:
472
527
  self.llm_tools_handled.add(t)
473
528
  self.llm_functions_handled.add(t)
@@ -662,7 +717,11 @@ class ChatAgent(Agent):
662
717
  # either the message is a str, or it is a fresh ChatDocument
663
718
  # different from the last message in the history
664
719
  llm_msgs = ChatDocument.to_LLMMessage(message, self.oai_tool_calls)
665
-
720
+ # LLM only responds to the content, so only those msgs with
721
+ # non-empty content should be kept
722
+ llm_msgs = [m for m in llm_msgs if m.content != ""]
723
+ if len(llm_msgs) == 0:
724
+ return [], 0
666
725
  # process tools if any
667
726
  done_tools = [m.tool_call_id for m in llm_msgs if m.role == Role.TOOL]
668
727
  self.oai_tool_calls = [
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import copy
4
4
  import json
5
+ from collections import OrderedDict
5
6
  from enum import Enum
6
7
  from typing import Any, Dict, List, Optional, Union, cast
7
8
 
@@ -96,14 +97,16 @@ class ChatDocument(Document):
96
97
  and so does the Task.run() method.
97
98
 
98
99
  Attributes:
99
- oai_tool_calls (List[OpenAIToolCall]): Tool-calls from an OpenAI-compatible API
100
- oai_tool_id2results (Dict[str, str]): Results of tool-calls from OpenAI
101
- (dict is a map of tool_id -> result)
100
+ oai_tool_calls (Optional[List[OpenAIToolCall]]):
101
+ Tool-calls from an OpenAI-compatible API
102
+ oai_tool_id2results (Optional[OrderedDict[str, str]]):
103
+ Results of tool-calls from OpenAI (dict is a map of tool_id -> result)
102
104
  oai_tool_choice: ToolChoiceTypes | Dict[str, str]: Param controlling how the
103
105
  LLM should choose tool-use in its response
104
106
  (auto, none, required, or a specific tool)
105
- function_call (LLMFunctionCall): Function-call from an OpenAI-compatible API
106
- (deprecated; use oai_tool_calls instead)
107
+ function_call (Optional[LLMFunctionCall]):
108
+ Function-call from an OpenAI-compatible API
109
+ (deprecated by OpenAI, in favor of tool-calls)
107
110
  tool_messages (List[ToolMessage]): Langroid ToolMessages extracted from
108
111
  - `content` field (via JSON parsing),
109
112
  - `oai_tool_calls`, or
@@ -113,10 +116,14 @@ class ChatDocument(Document):
113
116
  """
114
117
 
115
118
  oai_tool_calls: Optional[List[OpenAIToolCall]] = None
116
- oai_tool_id2result: Optional[Dict[str, str]] = None
119
+ oai_tool_id2result: Optional[OrderedDict[str, str]] = None
117
120
  oai_tool_choice: ToolChoiceTypes | Dict[str, Dict[str, str] | str] = "auto"
118
121
  function_call: Optional[LLMFunctionCall] = None
119
- tool_messages: List[ToolMessage] = []
122
+ tool_messages: List[ToolMessage] = [] # only handle-able tools
123
+ # all known tools in the msg that are in an agent's llm_tools_known list,
124
+ # even if non-used/handled
125
+ all_tool_messages: List[ToolMessage] = []
126
+
120
127
  metadata: ChatDocMetaData
121
128
  attachment: None | ChatDocAttachment = None
122
129
 
@@ -136,6 +143,8 @@ class ChatDocument(Document):
136
143
  def deepcopy(doc: ChatDocument) -> ChatDocument:
137
144
  new_doc = copy.deepcopy(doc)
138
145
  new_doc.metadata.id = ObjectRegistry.new_id()
146
+ new_doc.metadata.child_id = ""
147
+ new_doc.metadata.parent_id = ""
139
148
  ObjectRegistry.register_object(new_doc)
140
149
  return new_doc
141
150
 
@@ -160,7 +160,7 @@ class OpenAIAssistant(ChatAgent):
160
160
 
161
161
  def enable_message(
162
162
  self,
163
- message_class: Optional[Type[ToolMessage]],
163
+ message_class: Optional[Type[ToolMessage] | List[Type[ToolMessage]]],
164
164
  use: bool = True,
165
165
  handle: bool = True,
166
166
  force: bool = False,
@@ -173,6 +173,17 @@ class OpenAIAssistant(ChatAgent):
173
173
  fn-calling seems to pay attn to these, and if we don't want this,
174
174
  we should set this to False.
175
175
  """
176
+ if message_class is not None and isinstance(message_class, list):
177
+ for msg_class in message_class:
178
+ self.enable_message(
179
+ msg_class,
180
+ use=use,
181
+ handle=handle,
182
+ force=force,
183
+ require_recipient=require_recipient,
184
+ include_defaults=include_defaults,
185
+ )
186
+ return
176
187
  super().enable_message(
177
188
  message_class,
178
189
  use=use,
@@ -17,10 +17,11 @@ from typing import Any, Dict, List, Tuple
17
17
  import pandas as pd
18
18
 
19
19
  from langroid.agent.special.doc_chat_agent import DocChatAgent, DocChatAgentConfig
20
- from langroid.agent.special.lance_tools import QueryPlanTool
20
+ from langroid.agent.special.lance_tools import AnswerTool, QueryPlanTool
21
+ from langroid.agent.tools.orchestration import AgentDoneTool
21
22
  from langroid.mytypes import DocMetaData, Document
22
23
  from langroid.parsing.table_loader import describe_dataframe
23
- from langroid.utils.constants import DONE, NO_ANSWER
24
+ from langroid.utils.constants import NO_ANSWER
24
25
  from langroid.utils.pydantic_utils import (
25
26
  dataframe_to_documents,
26
27
  )
@@ -106,7 +107,7 @@ class LanceDocChatAgent(DocChatAgent):
106
107
  """
107
108
  return schema
108
109
 
109
- def query_plan(self, msg: QueryPlanTool) -> str:
110
+ def query_plan(self, msg: QueryPlanTool) -> AgentDoneTool | str:
110
111
  """
111
112
  Handle the LLM's use of the FilterTool.
112
113
  Temporarily set the config filter and either return the final answer
@@ -120,13 +121,15 @@ class LanceDocChatAgent(DocChatAgent):
120
121
  except Exception as e:
121
122
  logger.error(f"Error setting up documents: {e}")
122
123
  # say DONE with err msg so it goes back to LanceFilterAgent
123
- return f"""
124
- {DONE} Possible Filter Error:\n {e}
125
-
126
- Note that only the following fields are allowed in the filter
127
- of a query plan:
128
- {", ".join(self.config.filter_fields)}
129
- """
124
+ return AgentDoneTool(
125
+ content=f"""
126
+ Possible Filter Error:\n {e}
127
+
128
+ Note that only the following fields are allowed in the filter
129
+ of a query plan:
130
+ {", ".join(self.config.filter_fields)}
131
+ """
132
+ )
130
133
 
131
134
  # update the filter so it is used in the DocChatAgent
132
135
  self.config.filter = plan.filter or None
@@ -139,22 +142,25 @@ class LanceDocChatAgent(DocChatAgent):
139
142
  # The calc step can later be done with a separate Agent/Tool.
140
143
  if plan.query is None or plan.query.strip() == "":
141
144
  if plan.filter is None or plan.filter.strip() == "":
142
- return """DONE
143
- Cannot execute Query Plan since filter as well as
144
- rephrased query are empty.
145
- """
145
+ return AgentDoneTool(
146
+ content="""
147
+ Cannot execute Query Plan since filter as well as
148
+ rephrased query are empty.
149
+ """
150
+ )
146
151
  else:
147
152
  # no query to match, so just get all docs matching filter
148
153
  docs = self.vecdb.get_all_documents(plan.filter)
149
154
  else:
150
155
  _, docs = self.get_relevant_extracts(plan.query)
151
156
  if len(docs) == 0:
152
- return DONE + " " + NO_ANSWER
153
- result = self.vecdb.compute_from_docs(docs, plan.dataframe_calc)
154
- return DONE + " " + result
157
+ return AgentDoneTool(content=NO_ANSWER)
158
+ answer = self.vecdb.compute_from_docs(docs, plan.dataframe_calc)
155
159
  else:
156
160
  # pass on the query so LLM can handle it
157
- return plan.query
161
+ response = self.llm_response(plan.query)
162
+ answer = NO_ANSWER if response is None else response.content
163
+ return AgentDoneTool(tools=[AnswerTool(answer=answer)])
158
164
 
159
165
  def ingest_docs(
160
166
  self,
@@ -242,6 +248,7 @@ class LanceDocChatAgent(DocChatAgent):
242
248
  .replace("NOT", "not")
243
249
  .replace("'", "")
244
250
  .replace('"', "")
251
+ .replace(":", "--")
245
252
  )
246
253
 
247
254
  tbl = self.vecdb.client.open_table(self.vecdb.config.collection_name)
@@ -26,8 +26,8 @@ from langroid.agent.special.lance_tools import (
26
26
  QueryPlanAnswerTool,
27
27
  QueryPlanFeedbackTool,
28
28
  )
29
- from langroid.mytypes import Entity
30
- from langroid.utils.constants import DONE, NO_ANSWER, PASS
29
+ from langroid.agent.tools.orchestration import AgentDoneTool
30
+ from langroid.utils.constants import NO_ANSWER
31
31
 
32
32
  logger = logging.getLogger(__name__)
33
33
 
@@ -50,6 +50,9 @@ class QueryPlanCriticConfig(LanceQueryPlanAgentConfig):
50
50
  to create a QUERY PLAN, to be handled by an ASSISTANT.
51
51
  - PANDAS-LIKE FILTER, WHICH CAN BE EMPTY (and it's fine if results sound reasonable)
52
52
  FILTER SHOULD ONLY BE USED IF EXPLICITLY REQUIRED BY THE QUERY.
53
+ This filter selects the documents over which the REPHRASED QUERY will be applied,
54
+ thus naturally, the Re-phrased Query should NOT mention any FILTER fields,
55
+ since it applies to the documents AFTER FILTERING.
53
56
  - REPHRASED QUERY (CANNOT BE EMPTY) that will be used to match against the
54
57
  CONTENT (not filterable) of the documents.
55
58
  In general the REPHRASED QUERY should be relied upon to match the CONTENT
@@ -61,9 +64,31 @@ class QueryPlanCriticConfig(LanceQueryPlanAgentConfig):
61
64
  The assistant will answer based on documents whose CONTENTS match the QUERY,
62
65
  possibly REPHRASED.
63
66
  !!!!****THE REPHRASED QUERY SHOULD NEVER BE EMPTY****!!!
67
+
68
+
64
69
  - DATAFRAME CALCULATION, which must be a SINGLE LINE calculation (or empty),
65
70
  [NOTE ==> This calculation is applied AFTER the FILTER and REPHRASED QUERY.],
66
71
  - ANSWER received from an assistant that used this QUERY PLAN.
72
+ IT IS TOTALLY FINE FOR THE ANSWER TO NOT MENTION ANY FILTERING CONDITIONS,
73
+ or if the ANSWER STATEMENT is MISSING SOME CRITERIA in the ORIGINAL QUERY.
74
+
75
+ Here is an example of a VALID Plan + Answer:
76
+
77
+ ORIGINAL QUERY: "Which crime novels were written by Russian authors after 1900?"
78
+ FILTER: "author_nationality == 'Russian' and year_written > 1900"
79
+ REPHRASED QUERY: "crime novel" [NOTICE NO FILTER FIELDS MENTIONED!!!]
80
+ DATAFRAME CALC: ""
81
+ ANSWER: "The Master and Margarita by Mikhail Bulgakov"
82
+ [NOTICE the answer does NOT need to say "crime novel" or "russian author"]
83
+
84
+
85
+ Other examples of VALID ANSWER for a given ORIGINAL QUERY:
86
+
87
+ ORIGINAL QUERY: "Which mountain is taller than 8000 meters?"
88
+ ANSWER: "Mount Everest" [NOTICE no mention of "taller than 8000 meters"]
89
+
90
+ ORIGINAL QUERY: "Which country has hosted the most olympics?"
91
+ ANSWER: "United States" [NOTICE no mention of "most olympics"]
67
92
 
68
93
  In addition to the above SCHEMA fields there is a `content` field which:
69
94
  - CANNOT appear in a FILTER,
@@ -141,21 +166,28 @@ class QueryPlanCritic(ChatAgent):
141
166
  self.config = cfg
142
167
  self.enable_message(QueryPlanAnswerTool, use=False, handle=True)
143
168
  self.enable_message(QueryPlanFeedbackTool, use=True, handle=True)
169
+ self.enable_message(AgentDoneTool, use=False, handle=True)
170
+
171
+ def init_state(self) -> None:
172
+ self.expecting_feedback_tool = False
144
173
 
145
174
  def query_plan_answer(self, msg: QueryPlanAnswerTool) -> str:
146
175
  """Present query plan + answer in plain text (not JSON)
147
176
  so LLM can give feedback"""
177
+ self.expecting_feedback_tool = True
148
178
  return plain_text_query_plan(msg)
149
179
 
150
- def query_plan_feedback(self, msg: QueryPlanFeedbackTool) -> str:
180
+ def query_plan_feedback(self, msg: QueryPlanFeedbackTool) -> AgentDoneTool:
151
181
  """Format Valid so return to Query Planner"""
152
- return DONE + " " + PASS # return to Query Planner
182
+ self.expecting_feedback_tool = False
183
+ # indicate this task is Done, and return the tool as result
184
+ return AgentDoneTool(tools=[msg])
153
185
 
154
186
  def handle_message_fallback(
155
187
  self, msg: str | ChatDocument
156
188
  ) -> str | ChatDocument | None:
157
189
  """Remind the LLM to use QueryPlanFeedbackTool since it forgot"""
158
- if isinstance(msg, ChatDocument) and msg.metadata.sender == Entity.LLM:
190
+ if self.expecting_feedback_tool:
159
191
  return """
160
192
  You forgot to use the `query_plan_feedback` tool/function.
161
193
  Re-try your response using the `query_plan_feedback` tool/function,
@@ -15,17 +15,19 @@ This agent has access to two tools:
15
15
  """
16
16
 
17
17
  import logging
18
+ from typing import Optional
18
19
 
19
- import langroid as lr
20
20
  from langroid.agent.chat_agent import ChatAgent, ChatAgentConfig
21
21
  from langroid.agent.chat_document import ChatDocument
22
22
  from langroid.agent.special.lance_tools import (
23
+ AnswerTool,
23
24
  QueryPlan,
24
25
  QueryPlanAnswerTool,
25
26
  QueryPlanFeedbackTool,
26
27
  QueryPlanTool,
27
28
  )
28
- from langroid.utils.constants import DONE, NO_ANSWER, PASS_TO
29
+ from langroid.agent.tools.orchestration import AgentDoneTool, ForwardTool
30
+ from langroid.utils.constants import NO_ANSWER
29
31
 
30
32
  logger = logging.getLogger(__name__)
31
33
 
@@ -39,14 +41,14 @@ class LanceQueryPlanAgentConfig(ChatAgentConfig):
39
41
  max_retries: int = 5 # max number of retries for query plan
40
42
  use_functions_api = True
41
43
 
42
- system_message = f"""
44
+ system_message = """
43
45
  You will receive a QUERY, to be answered based on an EXTREMELY LARGE collection
44
46
  of documents you DO NOT have access to, but your ASSISTANT does.
45
47
  You only know that these documents have a special `content` field
46
48
  and additional FILTERABLE fields in the SCHEMA below, along with the
47
49
  SAMPLE VALUES for each field, and the DTYPE in PANDAS TERMINOLOGY.
48
50
 
49
- {{doc_schema}}
51
+ {doc_schema}
50
52
 
51
53
  Based on the QUERY and the above SCHEMA, your task is to determine a QUERY PLAN,
52
54
  consisting of:
@@ -116,10 +118,7 @@ class LanceQueryPlanAgentConfig(ChatAgentConfig):
116
118
  You may receive FEEDBACK on your QUERY PLAN and received ANSWER,
117
119
  from the 'QueryPlanCritic' who may offer suggestions for
118
120
  a better FILTER, REPHRASED QUERY, or DATAFRAME CALCULATION.
119
-
120
- If you keep getting feedback or keep getting a {NO_ANSWER} from the assistant
121
- at least 3 times, then simply say '{DONE} {NO_ANSWER}' and nothing else.
122
-
121
+
123
122
  At the BEGINNING if there is no query, ASK the user what they want to know.
124
123
  """
125
124
 
@@ -133,88 +132,128 @@ class LanceQueryPlanAgent(ChatAgent):
133
132
  def __init__(self, config: LanceQueryPlanAgentConfig):
134
133
  super().__init__(config)
135
134
  self.config: LanceQueryPlanAgentConfig = config
136
- self.curr_query_plan: QueryPlan | None = None
137
- # how many times re-trying query plan in response to feedback:
138
- self.n_retries: int = 0
139
- self.result: str = "" # answer received from LanceRAG
140
135
  # This agent should generate the QueryPlanTool
141
136
  # as well as handle it for validation
142
137
  self.enable_message(QueryPlanTool, use=True, handle=True)
143
138
  self.enable_message(QueryPlanFeedbackTool, use=False, handle=True)
139
+ self.enable_message(AnswerTool, use=False, handle=True)
140
+ # neither use nor handle! Added to "known" tools so that the Planner agent
141
+ # can avoid processing it
142
+ self.enable_message(QueryPlanAnswerTool, use=False, handle=False)
143
+ # LLM will not use this, so set use=False (Agent generates it)
144
+ self.enable_message(AgentDoneTool, use=False, handle=True)
145
+
146
+ def init_state(self) -> None:
147
+ self.curr_query_plan: QueryPlan | None = None
148
+ self.expecting_query_plan: bool = False
149
+ # how many times re-trying query plan in response to feedback:
150
+ self.n_retries: int = 0
151
+ self.n_query_plan_reminders: int = 0
152
+ self.result: str = "" # answer received from LanceRAG
153
+
154
+ def llm_response(
155
+ self, message: Optional[str | ChatDocument] = None
156
+ ) -> Optional[ChatDocument]:
157
+ self.expecting_query_plan = True
158
+ return super().llm_response(message)
144
159
 
145
- def query_plan(self, msg: QueryPlanTool) -> str:
146
- """Valid, forward to RAG Agent"""
160
+ def query_plan(self, msg: QueryPlanTool) -> ForwardTool | str:
161
+ """Valid, tool msg, forward chat_doc to RAG Agent.
162
+ Note this chat_doc will already have the
163
+ QueryPlanTool in its tool_messages list.
164
+ We just update the recipient to the doc_agent_name.
165
+ """
147
166
  # save, to be used to assemble QueryPlanResultTool
148
167
  if len(msg.plan.dataframe_calc.split("\n")) > 1:
149
168
  return "DATAFRAME CALCULATION must be a SINGLE LINE; Retry the `query_plan`"
150
169
  self.curr_query_plan = msg.plan
151
- return PASS_TO + self.config.doc_agent_name
170
+ self.expecting_query_plan = False
171
+
172
+ # To forward the QueryPlanTool to doc_agent, we could either:
173
+
174
+ # (a) insert `recipient` in the QueryPlanTool:
175
+ # QPWithRecipient = QueryPlanTool.require_recipient()
176
+ # qp = QPWithRecipient(**msg.dict(), recipient=self.config.doc_agent_name)
177
+ # return qp
178
+ #
179
+ # OR
180
+ #
181
+ # (b) create an agent response with recipient and tool_messages.
182
+ # response = self.create_agent_response(
183
+ # recipient=self.config.doc_agent_name, tool_messages=[msg]
184
+ # )
185
+ # return response
152
186
 
153
- def query_plan_feedback(self, msg: QueryPlanFeedbackTool) -> str:
187
+ # OR
188
+ # (c) use the ForwardTool:
189
+ return ForwardTool(agent=self.config.doc_agent_name)
190
+
191
+ def query_plan_feedback(self, msg: QueryPlanFeedbackTool) -> str | AgentDoneTool:
154
192
  """Process Critic feedback on QueryPlan + Answer from RAG Agent"""
155
193
  # We should have saved answer in self.result by this time,
156
194
  # since this Agent seeks feedback only after receiving RAG answer.
157
- if msg.suggested_fix == "":
158
- self.n_retries = 0
159
- # This means the Query Plan or Result is good, as judged by Critic
160
- if self.result == "":
161
- # This was feedback for query with no result
162
- return "QUERY PLAN LOOKS GOOD!"
163
- elif self.result == NO_ANSWER:
164
- return NO_ANSWER
165
- else: # non-empty and non-null answer
166
- return DONE + " " + self.result
195
+ if (
196
+ msg.suggested_fix == ""
197
+ and NO_ANSWER not in self.result
198
+ and self.result != ""
199
+ ):
200
+ # This means the result is good AND Query Plan is fine,
201
+ # as judged by Critic
202
+ # (Note sometimes critic may have empty suggested_fix even when
203
+ # the result is NO_ANSWER)
204
+ self.n_retries = 0 # good answer, so reset this
205
+ return AgentDoneTool(content=self.result)
167
206
  self.n_retries += 1
168
207
  if self.n_retries >= self.config.max_retries:
169
208
  # bail out to avoid infinite loop
170
209
  self.n_retries = 0
171
- return DONE + " " + NO_ANSWER
210
+ return AgentDoneTool(content=NO_ANSWER)
211
+
212
+ # there is a suggested_fix, OR the result is empty or NO_ANSWER
213
+ if self.result == "" or NO_ANSWER in self.result:
214
+ # if result is empty or NO_ANSWER, we should retry the query plan
215
+ feedback = """
216
+ There was no answer, which might mean there is a problem in your query.
217
+ """
218
+ suggested = "Retry the `query_plan` to try to get a non-null answer"
219
+ else:
220
+ feedback = msg.feedback
221
+ suggested = msg.suggested_fix
222
+
223
+ self.expecting_query_plan = True
224
+
172
225
  return f"""
173
226
  here is FEEDBACK about your QUERY PLAN, and a SUGGESTED FIX.
174
227
  Modify the QUERY PLAN if needed:
175
- FEEDBACK: {msg.feedback}
176
- SUGGESTED FIX: {msg.suggested_fix}
228
+ ANSWER: {self.result}
229
+ FEEDBACK: {feedback}
230
+ SUGGESTED FIX: {suggested}
177
231
  """
178
232
 
233
+ def answer_tool(self, msg: AnswerTool) -> QueryPlanAnswerTool:
234
+ """Handle AnswerTool received from LanceRagAgent:
235
+ Construct a QueryPlanAnswerTool with the answer"""
236
+ self.result = msg.answer # save answer to interpret feedback later
237
+ assert self.curr_query_plan is not None
238
+ query_plan_answer_tool = QueryPlanAnswerTool(
239
+ plan=self.curr_query_plan,
240
+ answer=msg.answer,
241
+ )
242
+ self.curr_query_plan = None # reset
243
+ return query_plan_answer_tool
244
+
179
245
  def handle_message_fallback(
180
246
  self, msg: str | ChatDocument
181
247
  ) -> str | ChatDocument | None:
182
248
  """
183
- Process answer received from RAG Agent:
184
- Construct a QueryPlanAnswerTool with the answer,
185
- and forward to Critic for feedback.
249
+ Remind to use QueryPlanTool if we are expecting it.
186
250
  """
187
- # TODO we don't need to use this fallback method. instead we can
188
- # first call result = super().agent_response(), and if result is None,
189
- # then we know there was no tool, so we run below code
190
- if (
191
- isinstance(msg, ChatDocument)
192
- and self.curr_query_plan is not None
193
- and msg.metadata.parent is not None
194
- ):
195
- # save result, to be used in query_plan_feedback()
196
- self.result = msg.content
197
- # assemble QueryPlanAnswerTool...
198
- query_plan_answer_tool = QueryPlanAnswerTool( # type: ignore
199
- plan=self.curr_query_plan,
200
- answer=self.result,
201
- )
202
- response_tmpl = self.create_agent_response()
203
- # ... add the QueryPlanAnswerTool to the response
204
- # (Notice how the Agent is directly sending a tool, not the LLM)
205
- response_tmpl.tool_messages = [query_plan_answer_tool]
206
- # set the recipient to the Critic so it can give feedback
207
- response_tmpl.metadata.recipient = self.config.critic_name
208
- self.curr_query_plan = None # reset
209
- return response_tmpl
210
- if (
211
- isinstance(msg, ChatDocument)
212
- and not self.has_tool_message_attempt(msg)
213
- and msg.metadata.sender == lr.Entity.LLM
214
- ):
215
- # remind LLM to use the QueryPlanFeedbackTool
251
+ if self.expecting_query_plan and self.n_query_plan_reminders < 5:
252
+ self.n_query_plan_reminders += 1
216
253
  return """
217
- You forgot to use the `query_plan` tool/function.
218
- Re-try your response using the `query_plan` tool/function.
254
+ You FORGOT to use the `query_plan` tool/function,
255
+ OR you had a WRONG JSON SYNTAX when trying to use it.
256
+ Re-try your response using the `query_plan` tool/function CORRECTLY.
219
257
  """
258
+ self.n_query_plan_reminders = 0 # reset
220
259
  return None
@@ -34,9 +34,17 @@ class QueryPlanTool(ToolMessage):
34
34
  plan: QueryPlan
35
35
 
36
36
 
37
+ class AnswerTool(ToolMessage):
38
+ """Wrapper for answer from LanceDocChatAgent"""
39
+
40
+ purpose: str = "To package the answer from LanceDocChatAgent"
41
+ request: str = "answer_tool"
42
+ answer: str
43
+
44
+
37
45
  class QueryPlanAnswerTool(ToolMessage):
38
- request = "query_plan_answer" # the agent method name that handles this tool
39
- purpose = """
46
+ request: str = "query_plan_answer" # the agent method name that handles this tool
47
+ purpose: str = """
40
48
  Assemble query <plan> and <answer>
41
49
  """
42
50
  plan: QueryPlan