langroid 0.6.7__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.
Files changed (37) hide show
  1. langroid/agent/base.py +499 -55
  2. langroid/agent/callbacks/chainlit.py +1 -1
  3. langroid/agent/chat_agent.py +191 -37
  4. langroid/agent/chat_document.py +142 -29
  5. langroid/agent/openai_assistant.py +20 -4
  6. langroid/agent/special/lance_doc_chat_agent.py +25 -18
  7. langroid/agent/special/lance_rag/critic_agent.py +37 -5
  8. langroid/agent/special/lance_rag/query_planner_agent.py +102 -63
  9. langroid/agent/special/lance_tools.py +10 -2
  10. langroid/agent/special/sql/sql_chat_agent.py +69 -13
  11. langroid/agent/task.py +179 -43
  12. langroid/agent/tool_message.py +19 -7
  13. langroid/agent/tools/__init__.py +5 -0
  14. langroid/agent/tools/orchestration.py +216 -0
  15. langroid/agent/tools/recipient_tool.py +6 -11
  16. langroid/agent/tools/rewind_tool.py +1 -1
  17. langroid/agent/typed_task.py +19 -0
  18. langroid/language_models/.chainlit/config.toml +121 -0
  19. langroid/language_models/.chainlit/translations/en-US.json +231 -0
  20. langroid/language_models/base.py +114 -12
  21. langroid/language_models/mock_lm.py +10 -1
  22. langroid/language_models/openai_gpt.py +260 -36
  23. langroid/mytypes.py +0 -1
  24. langroid/parsing/parse_json.py +19 -2
  25. langroid/utils/pydantic_utils.py +19 -0
  26. langroid/vector_store/base.py +3 -1
  27. langroid/vector_store/lancedb.py +2 -0
  28. {langroid-0.6.7.dist-info → langroid-0.9.0.dist-info}/METADATA +4 -1
  29. {langroid-0.6.7.dist-info → langroid-0.9.0.dist-info}/RECORD +32 -33
  30. pyproject.toml +2 -1
  31. langroid/agent/special/lance_rag_new/__init__.py +0 -9
  32. langroid/agent/special/lance_rag_new/critic_agent.py +0 -171
  33. langroid/agent/special/lance_rag_new/lance_rag_task.py +0 -144
  34. langroid/agent/special/lance_rag_new/query_planner_agent.py +0 -222
  35. langroid/agent/team.py +0 -1758
  36. {langroid-0.6.7.dist-info → langroid-0.9.0.dist-info}/LICENSE +0 -0
  37. {langroid-0.6.7.dist-info → langroid-0.9.0.dist-info}/WHEEL +0 -0
@@ -2,8 +2,9 @@ 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
- from typing import Any, List, Optional, Union, cast
7
+ from typing import Any, Dict, List, Optional, Union, cast
7
8
 
8
9
  from langroid.agent.tool_message import ToolMessage
9
10
  from langroid.language_models.base import (
@@ -11,7 +12,9 @@ from langroid.language_models.base import (
11
12
  LLMMessage,
12
13
  LLMResponse,
13
14
  LLMTokenUsage,
15
+ OpenAIToolCall,
14
16
  Role,
17
+ ToolChoiceTypes,
15
18
  )
16
19
  from langroid.mytypes import DocMetaData, Document, Entity
17
20
  from langroid.parsing.agent_chats import parse_message
@@ -51,6 +54,8 @@ class ChatDocMetaData(DocMetaData):
51
54
  agent_id: str = "" # ChatAgent that generated this message
52
55
  msg_idx: int = -1 # index of this message in the agent `message_history`
53
56
  sender: Entity # sender of the message
57
+ # tool_id corresponding to single tool result in ChatDocument.content
58
+ oai_tool_id: str | None = None
54
59
  tool_ids: List[str] = [] # stack of tool_ids; used by OpenAIAssistant
55
60
  block: None | Entity = None
56
61
  sender_name: str = ""
@@ -86,8 +91,39 @@ class ChatDocLoggerFields(BaseModel):
86
91
 
87
92
 
88
93
  class ChatDocument(Document):
94
+ """
95
+ Represents a message in a conversation among agents. All responders of an agent
96
+ have signature ChatDocument -> ChatDocument (modulo None, str, etc),
97
+ and so does the Task.run() method.
98
+
99
+ Attributes:
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)
104
+ oai_tool_choice: ToolChoiceTypes | Dict[str, str]: Param controlling how the
105
+ LLM should choose tool-use in its response
106
+ (auto, none, required, or a specific tool)
107
+ function_call (Optional[LLMFunctionCall]):
108
+ Function-call from an OpenAI-compatible API
109
+ (deprecated by OpenAI, in favor of tool-calls)
110
+ tool_messages (List[ToolMessage]): Langroid ToolMessages extracted from
111
+ - `content` field (via JSON parsing),
112
+ - `oai_tool_calls`, or
113
+ - `function_call`
114
+ metadata (ChatDocMetaData): Metadata for the message, e.g. sender, recipient.
115
+ attachment (None | ChatDocAttachment): Any additional data attached.
116
+ """
117
+
118
+ oai_tool_calls: Optional[List[OpenAIToolCall]] = None
119
+ oai_tool_id2result: Optional[OrderedDict[str, str]] = None
120
+ oai_tool_choice: ToolChoiceTypes | Dict[str, Dict[str, str] | str] = "auto"
89
121
  function_call: Optional[LLMFunctionCall] = None
90
- 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
+
91
127
  metadata: ChatDocMetaData
92
128
  attachment: None | ChatDocAttachment = None
93
129
 
@@ -107,6 +143,8 @@ class ChatDocument(Document):
107
143
  def deepcopy(doc: ChatDocument) -> ChatDocument:
108
144
  new_doc = copy.deepcopy(doc)
109
145
  new_doc.metadata.id = ObjectRegistry.new_id()
146
+ new_doc.metadata.child_id = ""
147
+ new_doc.metadata.parent_id = ""
110
148
  ObjectRegistry.register_object(new_doc)
111
149
  return new_doc
112
150
 
@@ -198,6 +236,24 @@ class ChatDocument(Document):
198
236
  if len(self.metadata.tool_ids) > 0:
199
237
  self.metadata.tool_ids.pop()
200
238
 
239
+ @staticmethod
240
+ def _clean_fn_call(fc: LLMFunctionCall | None) -> None:
241
+ # Sometimes an OpenAI LLM (esp gpt-4o) may generate a function-call
242
+ # with odditities:
243
+ # (a) the `name` is set, as well as `arguments.request` is set,
244
+ # and in langroid we use the `request` value as the `name`.
245
+ # In this case we override the `name` with the `request` value.
246
+ # (b) the `name` looks like "functions blah" or just "functions"
247
+ # In this case we strip the "functions" part.
248
+ if fc is None:
249
+ return
250
+ fc.name = fc.name.replace("functions", "").strip()
251
+ if fc.arguments is not None:
252
+ request = fc.arguments.get("request")
253
+ if request is not None and request != "":
254
+ fc.name = request
255
+ fc.arguments.pop("request")
256
+
201
257
  @staticmethod
202
258
  def from_LLMResponse(
203
259
  response: LLMResponse,
@@ -216,22 +272,14 @@ class ChatDocument(Document):
216
272
  if message in ["''", '""']:
217
273
  message = ""
218
274
  if response.function_call is not None:
219
- # Sometimes an OpenAI LLM (esp gpt-4o) may generate a function-call
220
- # with odditities:
221
- # (a) the `name` is set, as well as `arugments.request` is set,
222
- # and in langroid we use the `request` value as the `name`.
223
- # In this case we override the `name` with the `request` value.
224
- # (b) the `name` looks like "functions blah" or just "functions"
225
- # In this case we strip the "functions" part.
226
- fc = response.function_call
227
- fc.name = fc.name.replace("functions", "").strip()
228
- if fc.arguments is not None:
229
- request = fc.arguments.get("request")
230
- if request is not None and request != "":
231
- fc.name = request
232
- fc.arguments.pop("request")
275
+ ChatDocument._clean_fn_call(response.function_call)
276
+ if response.oai_tool_calls is not None:
277
+ # there must be at least one if it's not None
278
+ for oai_tc in response.oai_tool_calls:
279
+ ChatDocument._clean_fn_call(oai_tc.function)
233
280
  return ChatDocument(
234
281
  content=message,
282
+ oai_tool_calls=response.oai_tool_calls,
235
283
  function_call=response.function_call,
236
284
  metadata=ChatDocMetaData(
237
285
  source=Entity.LLM,
@@ -261,24 +309,33 @@ class ChatDocument(Document):
261
309
  )
262
310
 
263
311
  @staticmethod
264
- def to_LLMMessage(message: Union[str, "ChatDocument"]) -> LLMMessage:
312
+ def to_LLMMessage(
313
+ message: Union[str, "ChatDocument"],
314
+ oai_tools: Optional[List[OpenAIToolCall]] = None,
315
+ ) -> List[LLMMessage]:
265
316
  """
266
- Convert to LLMMessage for use with LLM.
317
+ Convert to list of LLMMessage, to incorporate into msg-history sent to LLM API.
318
+ Usually there will be just a single LLMMessage, but when the ChatDocument
319
+ contains results from multiple OpenAI tool-calls, we would have a sequence
320
+ LLMMessages, one per tool-call result.
267
321
 
268
322
  Args:
269
323
  message (str|ChatDocument): Message to convert.
324
+ oai_tools (Optional[List[OpenAIToolCall]]): Tool-calls currently awaiting
325
+ response, from the ChatAgent's latest message.
270
326
  Returns:
271
- LLMMessage: LLMMessage representation of this str or ChatDocument.
272
-
327
+ List[LLMMessage]: list of LLMMessages corresponding to this ChatDocument.
273
328
  """
274
329
  sender_name = None
275
330
  sender_role = Role.USER
276
331
  fun_call = None
277
- tool_id = ""
332
+ oai_tool_calls = None
333
+ tool_id = "" # for OpenAI Assistant
278
334
  chat_document_id: str = ""
279
335
  if isinstance(message, ChatDocument):
280
336
  content = message.content
281
337
  fun_call = message.function_call
338
+ oai_tool_calls = message.oai_tool_calls
282
339
  if message.metadata.sender == Entity.USER and fun_call is not None:
283
340
  # This may happen when a (parent agent's) LLM generates a
284
341
  # a Function-call, and it ends up being sent to the current task's
@@ -289,6 +346,10 @@ class ChatDocument(Document):
289
346
  # in the content of the message.
290
347
  content += " " + str(fun_call)
291
348
  fun_call = None
349
+ if message.metadata.sender == Entity.USER and oai_tool_calls is not None:
350
+ # same reasoning as for function-call above
351
+ content += " " + "\n\n".join(str(tc) for tc in oai_tool_calls)
352
+ oai_tool_calls = None
292
353
  sender_name = message.metadata.sender_name
293
354
  tool_ids = message.metadata.tool_ids
294
355
  tool_id = tool_ids[-1] if len(tool_ids) > 0 else ""
@@ -299,22 +360,74 @@ class ChatDocument(Document):
299
360
  message.metadata.parent is not None
300
361
  and message.metadata.parent.function_call is not None
301
362
  ):
363
+ # This is a response to a function call, so set the role to FUNCTION.
302
364
  sender_role = Role.FUNCTION
303
365
  sender_name = message.metadata.parent.function_call.name
366
+ elif oai_tools is not None and len(oai_tools) > 0:
367
+ pending_tool_ids = [tc.id for tc in oai_tools]
368
+ # The ChatAgent has pending OpenAI tool-call(s),
369
+ # so the current ChatDocument contains
370
+ # results for some/all/none of them.
371
+
372
+ if len(oai_tools) == 1:
373
+ # Case 1:
374
+ # There was exactly 1 pending tool-call, and in this case
375
+ # the result would be a plain string in `content`
376
+ return [
377
+ LLMMessage(
378
+ role=Role.TOOL,
379
+ tool_call_id=oai_tools[0].id,
380
+ content=content,
381
+ chat_document_id=chat_document_id,
382
+ )
383
+ ]
384
+
385
+ elif (
386
+ message.metadata.oai_tool_id is not None
387
+ and message.metadata.oai_tool_id in pending_tool_ids
388
+ ):
389
+ # Case 2:
390
+ # ChatDocument.content has result of a single tool-call
391
+ return [
392
+ LLMMessage(
393
+ role=Role.TOOL,
394
+ tool_call_id=message.metadata.oai_tool_id,
395
+ content=content,
396
+ chat_document_id=chat_document_id,
397
+ )
398
+ ]
399
+ elif message.oai_tool_id2result is not None:
400
+ # Case 2:
401
+ # There were > 1 tool-calls awaiting response,
402
+ assert (
403
+ len(message.oai_tool_id2result) > 1
404
+ ), "oai_tool_id2result must have more than 1 item."
405
+ return [
406
+ LLMMessage(
407
+ role=Role.TOOL,
408
+ tool_call_id=tool_id,
409
+ content=result,
410
+ chat_document_id=chat_document_id,
411
+ )
412
+ for tool_id, result in message.oai_tool_id2result.items()
413
+ ]
304
414
  elif message.metadata.sender == Entity.LLM:
305
415
  sender_role = Role.ASSISTANT
306
416
  else:
307
417
  # LLM can only respond to text content, so extract it
308
418
  content = message
309
419
 
310
- return LLMMessage(
311
- role=sender_role,
312
- tool_id=tool_id,
313
- content=content,
314
- function_call=fun_call,
315
- name=sender_name,
316
- chat_document_id=chat_document_id,
317
- )
420
+ return [
421
+ LLMMessage(
422
+ role=sender_role,
423
+ tool_id=tool_id, # for OpenAI Assistant
424
+ content=content,
425
+ function_call=fun_call,
426
+ tool_calls=oai_tool_calls,
427
+ name=sender_name,
428
+ chat_document_id=chat_document_id,
429
+ )
430
+ ]
318
431
 
319
432
 
320
433
  LLMMessage.update_forward_refs()
@@ -79,7 +79,7 @@ class OpenAIAssistantConfig(ChatAgentConfig):
79
79
  # set to True once we can add Assistant msgs in threads
80
80
  cache_responses: bool = True
81
81
  timeout: int = 30 # can be different from llm.timeout
82
- llm = OpenAIGPTConfig(chat_model=OpenAIChatModel.GPT4_TURBO)
82
+ llm = OpenAIGPTConfig(chat_model=OpenAIChatModel.GPT4o)
83
83
  tools: List[AssistantTool] = []
84
84
  files: List[str] = []
85
85
 
@@ -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,
@@ -192,7 +203,7 @@ class OpenAIAssistant(ChatAgent):
192
203
  self.set_system_message(sys_msg.content)
193
204
  if not self.config.use_functions_api:
194
205
  return
195
- functions, _ = self._function_args()
206
+ functions, _, _, _ = self._function_args()
196
207
  if functions is None:
197
208
  return
198
209
  # add the functions to the assistant:
@@ -720,7 +731,12 @@ class OpenAIAssistant(ChatAgent):
720
731
  """
721
732
  is_tool_output = False
722
733
  if message is not None:
723
- llm_msg = ChatDocument.to_LLMMessage(message)
734
+ # note: to_LLMMessage returns a list of LLMMessage,
735
+ # which is allowed to have len > 1, in case the msg
736
+ # represents results of multiple (non-assistant) tool-calls.
737
+ # But for OAI Assistant, we only assume exactly one tool-call at a time.
738
+ # TODO look into multi-tools
739
+ llm_msg = ChatDocument.to_LLMMessage(message)[0]
724
740
  tool_id = llm_msg.tool_id
725
741
  if tool_id in self.pending_tool_ids:
726
742
  if isinstance(message, ChatDocument):
@@ -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,