hyperforge-smart 1.0.0.post129__tar.gz → 1.0.0.post136__tar.gz

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 (18) hide show
  1. {hyperforge_smart-1.0.0.post129 → hyperforge_smart-1.0.0.post136}/PKG-INFO +1 -1
  2. {hyperforge_smart-1.0.0.post129 → hyperforge_smart-1.0.0.post136}/pyproject.toml +1 -1
  3. {hyperforge_smart-1.0.0.post129 → hyperforge_smart-1.0.0.post136}/src/hyperforge_smart/agent.py +119 -25
  4. {hyperforge_smart-1.0.0.post129 → hyperforge_smart-1.0.0.post136}/src/hyperforge_smart/config.py +8 -0
  5. {hyperforge_smart-1.0.0.post129 → hyperforge_smart-1.0.0.post136}/src/hyperforge_smart/prompts.py +5 -0
  6. {hyperforge_smart-1.0.0.post129 → hyperforge_smart-1.0.0.post136}/src/hyperforge_smart.egg-info/PKG-INFO +1 -1
  7. {hyperforge_smart-1.0.0.post129 → hyperforge_smart-1.0.0.post136}/tests/test_smart.py +325 -3
  8. {hyperforge_smart-1.0.0.post129 → hyperforge_smart-1.0.0.post136}/README.md +0 -0
  9. {hyperforge_smart-1.0.0.post129 → hyperforge_smart-1.0.0.post136}/setup.cfg +0 -0
  10. {hyperforge_smart-1.0.0.post129 → hyperforge_smart-1.0.0.post136}/src/hyperforge_smart/__init__.py +0 -0
  11. {hyperforge_smart-1.0.0.post129 → hyperforge_smart-1.0.0.post136}/src/hyperforge_smart/py.typed +0 -0
  12. {hyperforge_smart-1.0.0.post129 → hyperforge_smart-1.0.0.post136}/src/hyperforge_smart.egg-info/SOURCES.txt +0 -0
  13. {hyperforge_smart-1.0.0.post129 → hyperforge_smart-1.0.0.post136}/src/hyperforge_smart.egg-info/dependency_links.txt +0 -0
  14. {hyperforge_smart-1.0.0.post129 → hyperforge_smart-1.0.0.post136}/src/hyperforge_smart.egg-info/requires.txt +0 -0
  15. {hyperforge_smart-1.0.0.post129 → hyperforge_smart-1.0.0.post136}/src/hyperforge_smart.egg-info/top_level.txt +0 -0
  16. {hyperforge_smart-1.0.0.post129 → hyperforge_smart-1.0.0.post136}/tests/test_contexts.py +0 -0
  17. {hyperforge_smart-1.0.0.post129 → hyperforge_smart-1.0.0.post136}/tests/test_result_payload.py +0 -0
  18. {hyperforge_smart-1.0.0.post129 → hyperforge_smart-1.0.0.post136}/tests/test_smart_mcp_perplexity.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperforge_smart
3
- Version: 1.0.0.post129
3
+ Version: 1.0.0.post136
4
4
  Summary: Conditional Hyperforge agent
5
5
  Author-email: Nuclia <nucliadb@nuclia.com>
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hyperforge_smart"
7
- version = "1.0.0.post129"
7
+ version = "1.0.0.post136"
8
8
  license = "Apache-2.0"
9
9
  description = "Conditional Hyperforge agent"
10
10
  authors = [{ name = "Nuclia", email = "nucliadb@nuclia.com" }]
@@ -95,12 +95,16 @@ USER_FEEDBACK_TOOL = Tool(
95
95
  parameters={
96
96
  "type": "object",
97
97
  "properties": {
98
+ "decision_id": {
99
+ "type": "string",
100
+ "description": "Stable identifier for the underlying decision. Reuse it if the question is rephrased.",
101
+ },
98
102
  "question": {
99
103
  "type": "string",
100
104
  "description": "The question to ask the user",
101
105
  },
102
106
  },
103
- "required": ["question"],
107
+ "required": ["decision_id", "question"],
104
108
  },
105
109
  )
106
110
 
@@ -706,28 +710,45 @@ class SmartAgent(Agent[SmartAgentConfig], ContextAgent):
706
710
  messages: List[Message],
707
711
  tool_calls: List[Tuple[str, Any]],
708
712
  turn_label: str,
713
+ resolved_feedback: Optional[Dict[str, str]] = None,
709
714
  collected_contexts: Optional[List[Context]] = None,
710
715
  attempted_tool_calls: Optional[Dict[str, ToolAttempt]] = None,
711
716
  ) -> List[Tuple[str, Any]]:
712
717
  """Handle one turn of tool calls.
713
718
 
714
- If the LLM requested user feedback, sends the feedback request, records it as a
715
- step, stores it via _process_results and returns (results, True) so the caller
716
- can ``continue`` to the next iteration without executing other tools.
717
-
718
- Otherwise executes all tool calls in parallel, records an execution step and
719
- returns (results, False).
719
+ New feedback pauses other tool execution for the turn. Already-resolved
720
+ feedback becomes a synthetic result, and any other requested tools continue.
720
721
  """
721
722
  agent_path = f"/context/{self.config.id or 'default'}"
723
+ synthetic_results: List[Tuple[str, Any]] = []
722
724
 
723
725
  # --- user_feedback path ---
724
726
  if any(name == "user_feedback" for name, _ in tool_calls):
725
727
  for name, args in tool_calls:
726
728
  if name == "user_feedback":
729
+ decision_id: Optional[str] = (
730
+ args.get("decision_id") if args else None
731
+ )
727
732
  feedback_question: Optional[str] = (
728
733
  args.get("question") if args else None
729
734
  )
730
735
  if feedback_question:
736
+ decision_id = decision_id or (
737
+ f"legacy:{feedback_question.strip().lower()}"
738
+ )
739
+ if (
740
+ resolved_feedback is not None
741
+ and decision_id in resolved_feedback
742
+ ):
743
+ cached_feedback_result = (
744
+ "user_feedback",
745
+ "Feedback resolved. Do not request this feedback again. "
746
+ f"Decision ID: {decision_id}\n"
747
+ f"Question: {feedback_question}\n"
748
+ f"Response: {resolved_feedback[decision_id]}",
749
+ )
750
+ synthetic_results.append(cached_feedback_result)
751
+ continue
731
752
  feedback = Feedback(
732
753
  request_id=memory.get_session_id(),
733
754
  question=feedback_question,
@@ -744,10 +765,7 @@ class SmartAgent(Agent[SmartAgentConfig], ContextAgent):
744
765
  answer = await memory.send_feedback(feedback)
745
766
  feedback_text = (
746
767
  answer.response
747
- if (
748
- answer is not None
749
- and answer.request_id == memory.get_session_id()
750
- )
768
+ if answer is not None
751
769
  else "(No response received)"
752
770
  )
753
771
  messages.append(Message(author=Author.USER, text=feedback_text))
@@ -762,10 +780,16 @@ class SmartAgent(Agent[SmartAgentConfig], ContextAgent):
762
780
  )
763
781
  feedback_result: Tuple[str, Any] = (
764
782
  "user_feedback",
765
- feedback_text,
783
+ "Feedback resolved. Do not request this feedback again. "
784
+ f"Decision ID: {decision_id}\n"
785
+ f"Question: {feedback_question}\n"
786
+ f"Response: {feedback_text}",
766
787
  )
788
+ if resolved_feedback is not None:
789
+ resolved_feedback[decision_id] = feedback_text
790
+ feedback_results = [*synthetic_results, feedback_result]
767
791
  result_texts = self._process_results(
768
- [feedback_result], collected_contexts=collected_contexts
792
+ feedback_results, collected_contexts=collected_contexts
769
793
  )
770
794
  if result_texts:
771
795
  messages.append(
@@ -774,8 +798,20 @@ class SmartAgent(Agent[SmartAgentConfig], ContextAgent):
774
798
  text="\n\n".join(result_texts),
775
799
  )
776
800
  )
777
- return [feedback_result]
778
- return []
801
+ return feedback_results
802
+
803
+ tool_calls = [
804
+ (name, args) for name, args in tool_calls if name != "user_feedback"
805
+ ]
806
+ if not tool_calls:
807
+ result_texts = self._process_results(
808
+ synthetic_results, collected_contexts=collected_contexts
809
+ )
810
+ if result_texts:
811
+ messages.append(
812
+ Message(author=Author.NUCLIA, text="\n\n".join(result_texts))
813
+ )
814
+ return synthetic_results
779
815
 
780
816
  # --- normal tool execution path ---
781
817
  attempted_tool_calls = (
@@ -818,16 +854,34 @@ class SmartAgent(Agent[SmartAgentConfig], ContextAgent):
818
854
  )
819
855
  calls_to_execute.append((name, tool_arguments))
820
856
 
857
+ async def execute_with_delegation(
858
+ tool_name: str, tool_arguments: Dict[str, Any]
859
+ ) -> Tuple[str, Any]:
860
+ await memory.add_step(
861
+ step_module=self.config.module,
862
+ step_title=self.step_title(f"Delegating to {tool_name}"),
863
+ step_reason="Starting selected tool",
864
+ step_agent_path=agent_path,
865
+ step_value=tool_name,
866
+ timeit=0,
867
+ )
868
+ return await self.execute_tool_call(
869
+ memory, manager, tool_name, tool_arguments
870
+ )
871
+
821
872
  executed_results = []
822
- if calls_to_execute:
873
+ if calls_to_execute and self.config.parallel_tool_calls:
823
874
  executed_results = await asyncio.gather(
824
875
  *[
825
- self.execute_tool_call(memory, manager, name, args)
876
+ execute_with_delegation(name, args)
826
877
  for name, args in calls_to_execute
827
878
  ]
828
879
  )
880
+ elif calls_to_execute:
881
+ for name, args in calls_to_execute:
882
+ executed_results.append(await execute_with_delegation(name, args))
829
883
 
830
- results = [*executed_results, *skipped_results]
884
+ results = [*synthetic_results, *executed_results, *skipped_results]
831
885
  for (tool_name, tool_arguments), (_, result) in zip(
832
886
  calls_to_execute, executed_results
833
887
  ):
@@ -867,6 +921,20 @@ class SmartAgent(Agent[SmartAgentConfig], ContextAgent):
867
921
  )
868
922
  return list(results)
869
923
 
924
+ @staticmethod
925
+ def _has_unresolved_feedback(
926
+ tool_calls: List[Tuple[str, Any]], resolved_feedback: Dict[str, str]
927
+ ) -> bool:
928
+ for name, args in tool_calls:
929
+ if name != "user_feedback" or not args or not args.get("question"):
930
+ continue
931
+ decision_id = args.get("decision_id") or (
932
+ f"legacy:{args['question'].strip().lower()}"
933
+ )
934
+ if decision_id not in resolved_feedback:
935
+ return True
936
+ return False
937
+
870
938
  async def _reactive_loop(
871
939
  self,
872
940
  question: str,
@@ -899,6 +967,7 @@ class SmartAgent(Agent[SmartAgentConfig], ContextAgent):
899
967
  finished = False
900
968
  total_input_tokens = 0.0
901
969
  total_output_tokens = 0.0
970
+ resolved_feedback: Dict[str, str] = {}
902
971
  attempted_tool_calls: Dict[str, ToolAttempt] = {}
903
972
  while not finished and iteration < self.config.max_iterations:
904
973
  iteration += 1
@@ -937,20 +1006,28 @@ class SmartAgent(Agent[SmartAgentConfig], ContextAgent):
937
1006
  output_nuclia_tokens=output_tokens,
938
1007
  )
939
1008
 
940
- if not tool_calls or any(name == "task_complete" for name, _ in tool_calls):
1009
+ if not tool_calls:
941
1010
  finished = True
942
1011
  break
943
1012
 
944
1013
  # Execute tool calls (handles user_feedback and normal tool calls)
1014
+ requested_new_feedback = SmartAgent._has_unresolved_feedback(
1015
+ tool_calls, resolved_feedback
1016
+ )
945
1017
  _ = await self._execute_tool_calls_turn(
946
1018
  memory=memory,
947
1019
  manager=manager,
948
1020
  messages=messages,
949
1021
  tool_calls=tool_calls,
950
1022
  turn_label=f"iteration {iteration}/{self.config.max_iterations}",
1023
+ resolved_feedback=resolved_feedback,
951
1024
  collected_contexts=contexts,
952
1025
  attempted_tool_calls=attempted_tool_calls,
953
1026
  )
1027
+ if not requested_new_feedback and any(
1028
+ name == "task_complete" for name, _ in tool_calls
1029
+ ):
1030
+ finished = True
954
1031
 
955
1032
  reason = (
956
1033
  "Task complete signal received"
@@ -1026,6 +1103,8 @@ class SmartAgent(Agent[SmartAgentConfig], ContextAgent):
1026
1103
  steps: List[Dict[str, Any]],
1027
1104
  summary: str,
1028
1105
  tools: List[Tool],
1106
+ messages: List[Message],
1107
+ resolved_feedback: Dict[str, str],
1029
1108
  ) -> Tuple[List[Tuple[str, Any]], float, float, List[ToolAttempt]]:
1030
1109
  """Run the executor LLM turn: call tools guided by the current plan steps."""
1031
1110
  system = PLAN_EXECUTE_EXECUTOR_SYSTEM_PROMPT_TEMPLATE.render(
@@ -1034,10 +1113,6 @@ class SmartAgent(Agent[SmartAgentConfig], ContextAgent):
1034
1113
  summary=summary,
1035
1114
  extra_instructions=self.config.extra_prompt or "",
1036
1115
  )
1037
- messages: List[Message] = [
1038
- Message(author=Author.USER, text=question),
1039
- ]
1040
-
1041
1116
  all_results: List[Tuple[str, Any]] = []
1042
1117
  total_input_tokens = 0.0
1043
1118
  total_output_tokens = 0.0
@@ -1060,19 +1135,34 @@ class SmartAgent(Agent[SmartAgentConfig], ContextAgent):
1060
1135
 
1061
1136
  tool_calls = list(iterate_tools_resp(resp))
1062
1137
 
1063
- if not tool_calls or any(name == "task_complete" for name, _ in tool_calls):
1138
+ if not tool_calls:
1139
+ finished = True
1140
+ break
1141
+
1142
+ executable_calls = [
1143
+ (name, args) for name, args in tool_calls if name != "task_complete"
1144
+ ]
1145
+ if not executable_calls:
1064
1146
  finished = True
1065
1147
  break
1066
1148
 
1149
+ requested_new_feedback = SmartAgent._has_unresolved_feedback(
1150
+ executable_calls, resolved_feedback
1151
+ )
1067
1152
  results = await self._execute_tool_calls_turn(
1068
1153
  memory=memory,
1069
1154
  manager=manager,
1070
1155
  messages=messages,
1071
- tool_calls=tool_calls,
1156
+ tool_calls=executable_calls,
1072
1157
  turn_label=f"executor turn {executor_turn}/{max_executor_turns}",
1158
+ resolved_feedback=resolved_feedback,
1073
1159
  attempted_tool_calls=attempted_tool_calls,
1074
1160
  )
1075
1161
  all_results.extend(results)
1162
+ if not requested_new_feedback and any(
1163
+ name == "task_complete" for name, _ in tool_calls
1164
+ ):
1165
+ finished = True
1076
1166
 
1077
1167
  return (
1078
1168
  all_results,
@@ -1100,6 +1190,8 @@ class SmartAgent(Agent[SmartAgentConfig], ContextAgent):
1100
1190
  iteration = 0
1101
1191
  total_input_tokens = 0.0
1102
1192
  total_output_tokens = 0.0
1193
+ executor_messages = [Message(author=Author.USER, text=question)]
1194
+ resolved_feedback: Dict[str, str] = {}
1103
1195
  status = "done"
1104
1196
  steps: List[Dict[str, Any]] = []
1105
1197
 
@@ -1172,6 +1264,8 @@ class SmartAgent(Agent[SmartAgentConfig], ContextAgent):
1172
1264
  steps=steps,
1173
1265
  summary=summary,
1174
1266
  tools=tools,
1267
+ messages=executor_messages,
1268
+ resolved_feedback=resolved_feedback,
1175
1269
  )
1176
1270
  total_input_tokens += exec_in_tokens
1177
1271
  total_output_tokens += exec_out_tokens
@@ -121,6 +121,14 @@ class SmartAgentConfig(ContextAgentConfig):
121
121
  title="Max iterations",
122
122
  description="Maximum number of planning and execution iterations before stopping",
123
123
  )
124
+ parallel_tool_calls: bool = Field(
125
+ default=True,
126
+ title="Parallel tool calls",
127
+ description=(
128
+ "Execute tool calls selected in the same turn concurrently. Disable for "
129
+ "interactive or stateful tools that must complete in order."
130
+ ),
131
+ )
124
132
  extra_prompt: Optional[str] = Field(
125
133
  None,
126
134
  title="Extra prompt",
@@ -11,6 +11,9 @@ REACTIVE_SYSTEM_PROMPT_TEMPLATE = PROMPT_ENVIRONMENT.from_string(
11
11
  You are a smart assistant that selects tools to gather information needed to answer a user's question.
12
12
  Choose the best tool or tools for the task. You may call multiple tools in one turn.
13
13
  Call task_complete when you have enough information to answer the question.
14
+ When a "Feedback resolved" result exists for a decision ID, that decision is
15
+ answered. Reuse the same decision ID if wording changes and continue with the
16
+ recorded response.
14
17
  Guidelines:
15
18
  - Prefer finishing as soon as the gathered information is sufficient to answer the question.
16
19
  - Do not repeat the exact same tool call with the same arguments unless there is a clear reason that new information will be produced.
@@ -151,6 +154,8 @@ described in the retrieval plan below.
151
154
 
152
155
  Call the tools needed to fulfil this plan. You may call multiple tools.
153
156
  Call task_complete once you have executed all planned retrieval steps.
157
+ When a tool result says "Feedback resolved", its decision ID is already answered.
158
+ Reuse that ID if wording changes and continue using the recorded response.
154
159
  {% if extra_instructions %}
155
160
  Extra instructions: {{ extra_instructions }}
156
161
  {% endif %}"""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperforge_smart
3
- Version: 1.0.0.post129
3
+ Version: 1.0.0.post136
4
4
  Summary: Conditional Hyperforge agent
5
5
  Author-email: Nuclia <nucliadb@nuclia.com>
6
6
  License-Expression: Apache-2.0
@@ -1,7 +1,7 @@
1
1
  import os
2
2
  from copy import deepcopy
3
3
  from dataclasses import dataclass
4
- from types import SimpleNamespace
4
+ from types import MethodType, SimpleNamespace
5
5
  from typing import Any, ClassVar, cast
6
6
  from unittest.mock import patch
7
7
 
@@ -97,6 +97,319 @@ CONFIG = {
97
97
  }
98
98
 
99
99
 
100
+ @pytest.mark.asyncio
101
+ async def test_plan_executor_preserves_messages_between_iterations():
102
+ observed_messages = []
103
+
104
+ async def choose_tools(_self, *, messages, **_kwargs):
105
+ observed_messages.append(messages)
106
+ return SimpleNamespace(tools=None), 0, 0
107
+
108
+ smart: Any = SimpleNamespace(
109
+ config=SimpleNamespace(extra_prompt=None, max_iterations=1),
110
+ choose_tools=MethodType(choose_tools, object()),
111
+ _planner_attempts_from_registry=SmartAgent._planner_attempts_from_registry,
112
+ )
113
+ memory: Any = SimpleNamespace(get_tracking_info=lambda: None)
114
+ manager: Any = None
115
+ messages = [
116
+ Message(author=Author.USER, text="Coordinate the incident"),
117
+ Message(author=Author.NUCLIA, text="Tour Manager approved the change"),
118
+ ]
119
+
120
+ await SmartAgent._call_executor(
121
+ smart,
122
+ memory=memory,
123
+ manager=manager,
124
+ question="Coordinate the incident",
125
+ steps=[{"description": "Continue coordination", "reason": "Approval received"}],
126
+ summary="The Tour Manager approved the change",
127
+ tools=[],
128
+ messages=messages,
129
+ resolved_feedback={},
130
+ )
131
+
132
+ assert observed_messages == [messages]
133
+ assert observed_messages[0] is messages
134
+ assert observed_messages[0][-1].text == "Tour Manager approved the change"
135
+
136
+
137
+ @pytest.mark.asyncio
138
+ async def test_feedback_decision_id_survives_rephrasing():
139
+ class Memory:
140
+ calls = 0
141
+
142
+ def get_session_id(self):
143
+ return "session-1"
144
+
145
+ async def send_feedback(self, feedback):
146
+ self.calls += 1
147
+ return UserToAgentInteraction(
148
+ request_id=feedback.request_id,
149
+ response="Approved",
150
+ )
151
+
152
+ async def add_step(self, **_kwargs):
153
+ return None
154
+
155
+ memory: Any = Memory()
156
+ manager: Any = None
157
+ smart: Any = SimpleNamespace(
158
+ config=SimpleNamespace(
159
+ id="operations",
160
+ module="smart",
161
+ feedback_timeout=5_000,
162
+ max_tool_result_bytes=None,
163
+ max_tool_result_item_bytes=None,
164
+ ),
165
+ step_title=lambda title: title,
166
+ )
167
+ smart._process_results = MethodType(SmartAgent._process_results, smart)
168
+ smart._inspect_result = MethodType(SmartAgent._inspect_result, smart)
169
+ messages: list[Message] = []
170
+ resolved_feedback: dict[str, str] = {}
171
+
172
+ for question in (
173
+ "Approve the shorter set?",
174
+ "Can we reduce the set to 60 minutes?",
175
+ ):
176
+ await SmartAgent._execute_tool_calls_turn(
177
+ smart,
178
+ memory=memory,
179
+ manager=manager,
180
+ messages=messages,
181
+ tool_calls=[
182
+ (
183
+ "user_feedback",
184
+ {"decision_id": "set-duration", "question": question},
185
+ )
186
+ ],
187
+ turn_label="test",
188
+ resolved_feedback=resolved_feedback,
189
+ )
190
+
191
+ assert memory.calls == 1
192
+ assert resolved_feedback == {"set-duration": "Approved"}
193
+ assert "Decision ID: set-duration" in messages[-1].text
194
+
195
+
196
+ @pytest.mark.asyncio
197
+ async def test_different_feedback_decisions_are_both_requested():
198
+ class Memory:
199
+ calls = 0
200
+
201
+ def get_session_id(self):
202
+ return "session-1"
203
+
204
+ async def send_feedback(self, feedback):
205
+ self.calls += 1
206
+ return UserToAgentInteraction(
207
+ request_id=feedback.request_id,
208
+ response="Approved",
209
+ )
210
+
211
+ async def add_step(self, **_kwargs):
212
+ return None
213
+
214
+ memory: Any = Memory()
215
+ manager: Any = None
216
+ smart: Any = SimpleNamespace(
217
+ config=SimpleNamespace(
218
+ id="operations",
219
+ module="smart",
220
+ feedback_timeout=5_000,
221
+ max_tool_result_bytes=None,
222
+ max_tool_result_item_bytes=None,
223
+ ),
224
+ step_title=lambda title: title,
225
+ )
226
+ smart._process_results = MethodType(SmartAgent._process_results, smart)
227
+ smart._inspect_result = MethodType(SmartAgent._inspect_result, smart)
228
+ messages: list[Message] = []
229
+ resolved_feedback: dict[str, str] = {}
230
+
231
+ for decision_id in ("set-duration", "gate-activation"):
232
+ await SmartAgent._execute_tool_calls_turn(
233
+ smart,
234
+ memory=memory,
235
+ manager=manager,
236
+ messages=messages,
237
+ tool_calls=[
238
+ (
239
+ "user_feedback",
240
+ {"decision_id": decision_id, "question": f"Approve {decision_id}?"},
241
+ )
242
+ ],
243
+ turn_label="test",
244
+ resolved_feedback=resolved_feedback,
245
+ )
246
+
247
+ assert memory.calls == 2
248
+ assert resolved_feedback == {
249
+ "set-duration": "Approved",
250
+ "gate-activation": "Approved",
251
+ }
252
+
253
+
254
+ @pytest.mark.asyncio
255
+ async def test_feedback_response_with_upstream_request_id_is_cached():
256
+ class Memory:
257
+ def get_session_id(self):
258
+ return "session-1"
259
+
260
+ async def send_feedback(self, _feedback):
261
+ return UserToAgentInteraction(
262
+ request_id="upstream-a2a-request", response="Approved"
263
+ )
264
+
265
+ async def add_step(self, **_kwargs):
266
+ return None
267
+
268
+ smart: Any = SimpleNamespace(
269
+ config=SimpleNamespace(
270
+ id="operations",
271
+ module="smart",
272
+ feedback_timeout=5_000,
273
+ max_tool_result_bytes=None,
274
+ max_tool_result_item_bytes=None,
275
+ ),
276
+ step_title=lambda title: title,
277
+ )
278
+ smart._process_results = MethodType(SmartAgent._process_results, smart)
279
+ smart._inspect_result = MethodType(SmartAgent._inspect_result, smart)
280
+ resolved_feedback: dict[str, str] = {}
281
+ memory: Any = Memory()
282
+ manager: Any = None
283
+
284
+ await SmartAgent._execute_tool_calls_turn(
285
+ smart,
286
+ memory=memory,
287
+ manager=manager,
288
+ messages=[],
289
+ tool_calls=[
290
+ (
291
+ "user_feedback",
292
+ {"decision_id": "set-duration", "question": "Approve?"},
293
+ )
294
+ ],
295
+ turn_label="test",
296
+ resolved_feedback=resolved_feedback,
297
+ )
298
+
299
+ assert resolved_feedback == {"set-duration": "Approved"}
300
+
301
+
302
+ @pytest.mark.asyncio
303
+ async def test_cached_feedback_does_not_skip_other_tools_in_same_turn():
304
+ class Memory:
305
+ async def add_step(self, **_kwargs):
306
+ return None
307
+
308
+ async def execute_tool_call(_memory, _manager, name, _args):
309
+ return name, "tool result"
310
+
311
+ smart: Any = SimpleNamespace(
312
+ config=SimpleNamespace(
313
+ id="operations",
314
+ module="smart",
315
+ parallel_tool_calls=False,
316
+ max_tool_result_bytes=None,
317
+ max_tool_result_item_bytes=None,
318
+ ),
319
+ step_title=lambda title: title,
320
+ execute_tool_call=execute_tool_call,
321
+ )
322
+ smart._process_results = MethodType(SmartAgent._process_results, smart)
323
+ smart._inspect_result = MethodType(SmartAgent._inspect_result, smart)
324
+ smart._tool_call_key = SmartAgent._tool_call_key
325
+ smart._classify_tool_result = SmartAgent._classify_tool_result
326
+ memory: Any = Memory()
327
+ manager: Any = None
328
+
329
+ results = await SmartAgent._execute_tool_calls_turn(
330
+ smart,
331
+ memory=memory,
332
+ manager=manager,
333
+ messages=[],
334
+ tool_calls=[
335
+ (
336
+ "user_feedback",
337
+ {"decision_id": "set-duration", "question": "Reduce the set?"},
338
+ ),
339
+ ("venue_lookup", {"gate": "C"}),
340
+ ],
341
+ turn_label="test",
342
+ resolved_feedback={"set-duration": "Approved"},
343
+ )
344
+
345
+ assert [name for name, _ in results] == ["user_feedback", "venue_lookup"]
346
+ assert results[1] == ("venue_lookup", "tool result")
347
+
348
+
349
+ @pytest.mark.asyncio
350
+ async def test_executor_runs_tools_returned_with_task_complete():
351
+ smart: Any = SimpleNamespace(
352
+ config=SimpleNamespace(max_iterations=1, extra_prompt=None),
353
+ )
354
+
355
+ async def choose_tools(**_kwargs):
356
+ return (
357
+ SimpleNamespace(
358
+ tools={
359
+ "calls": [
360
+ SimpleNamespace(
361
+ function=SimpleNamespace(
362
+ name="a2a_query_of_logistics", arguments={}
363
+ )
364
+ ),
365
+ SimpleNamespace(
366
+ function=SimpleNamespace(name="task_complete", arguments={})
367
+ ),
368
+ ]
369
+ }
370
+ ),
371
+ 0,
372
+ 0,
373
+ )
374
+
375
+ executed_calls = []
376
+
377
+ async def execute_turn(**kwargs):
378
+ executed_calls.extend(kwargs["tool_calls"])
379
+ return [("a2a_query of logistics", "result")]
380
+
381
+ smart.choose_tools = choose_tools
382
+ smart._execute_tool_calls_turn = execute_turn
383
+ smart._planner_attempts_from_registry = SmartAgent._planner_attempts_from_registry
384
+ memory: Any = SimpleNamespace(get_tracking_info=lambda: None)
385
+ manager: Any = None
386
+
387
+ results, _, _, _ = await SmartAgent._call_executor(
388
+ smart,
389
+ memory=memory,
390
+ manager=manager,
391
+ question="question",
392
+ steps=[],
393
+ summary="",
394
+ tools=[],
395
+ messages=[],
396
+ resolved_feedback={},
397
+ )
398
+
399
+ assert executed_calls == [("a2a_query_of_logistics", {})]
400
+ assert results == [("a2a_query of logistics", "result")]
401
+
402
+
403
+ def test_new_feedback_prevents_same_turn_completion():
404
+ calls = [
405
+ ("user_feedback", {"decision_id": "approval", "question": "Approve?"}),
406
+ ("task_complete", {}),
407
+ ]
408
+
409
+ assert SmartAgent._has_unresolved_feedback(calls, {})
410
+ assert not SmartAgent._has_unresolved_feedback(calls, {"approval": "yes"})
411
+
412
+
100
413
  @pytest.mark.asyncio
101
414
  async def test_choose_tools_uses_auto_tool_choice():
102
415
  smart_agent = SmartAgent(
@@ -367,7 +680,7 @@ async def test_smart_with_user_feedback():
367
680
 
368
681
  async def mock_send_feedback(feedback):
369
682
  return UserToAgentInteraction(
370
- request_id=feedback.request_id,
683
+ request_id="upstream-a2a-request",
371
684
  response="Snoopy the pet",
372
685
  )
373
686
 
@@ -391,7 +704,16 @@ async def test_smart_with_user_feedback():
391
704
  )
392
705
 
393
706
  assert question_memory.final_answer
394
- assert "Feedback response: Snoopy the pet" in question_memory.steps[1].value
707
+ feedback_steps = [
708
+ step
709
+ for step in question_memory.steps
710
+ if step.module == "smart" and "user feedback" in step.title.lower()
711
+ ]
712
+ assert feedback_steps, "Expected a user feedback step"
713
+ assert any(
714
+ "Feedback response: Snoopy the pet" in (step.value or "")
715
+ for step in feedback_steps
716
+ )
395
717
  assert any(
396
718
  keyword.lower() in question_memory.final_answer.lower()
397
719
  for keyword in ["Snoopy", "dog", "Carmen"]