aiecs 1.7.6__py3-none-any.whl → 1.7.17__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.

Potentially problematic release.


This version of aiecs might be problematic. Click here for more details.

@@ -43,6 +43,67 @@ class HybridAgent(BaseAIAgent):
43
43
  - BaseLLMClient: Standard LLM clients (OpenAI, xAI, etc.)
44
44
  - Custom clients: Any object implementing LLMClientProtocol (duck typing)
45
45
 
46
+ **ReAct Format Reference (for callers to include in their prompts):**
47
+
48
+ The caller is responsible for ensuring the LLM follows the correct format.
49
+ Below are the standard formats that HybridAgent expects:
50
+
51
+ CORRECT FORMAT EXAMPLE::
52
+
53
+ <THOUGHT>
54
+ I need to search for information about the weather. Let me use the search tool.
55
+ </THOUGHT>
56
+
57
+ TOOL: search
58
+ OPERATION: query
59
+ PARAMETERS: {"q": "weather today"}
60
+
61
+ <OBSERVATION>
62
+ The search tool returned: Today's weather is sunny, 72°F.
63
+ </OBSERVATION>
64
+
65
+ <THOUGHT>
66
+ I have the weather information. Now I can provide the final response.
67
+ </THOUGHT>
68
+
69
+ FINAL RESPONSE: Today's weather is sunny, 72°F. finish
70
+
71
+ INCORRECT FORMAT (DO NOT DO THIS)::
72
+
73
+ <THOUGHT>
74
+ I need to search.
75
+ TOOL: search
76
+ OPERATION: query
77
+ </THOUGHT>
78
+ ❌ Tool calls must be OUTSIDE the <THOUGHT> and <OBSERVATION> tags
79
+
80
+ <THOUGHT>
81
+ I know the answer.
82
+ FINAL RESPONSE: The answer is... finish
83
+ </THOUGHT>
84
+ ❌ Final responses must be OUTSIDE the <THOUGHT> and <OBSERVATION> tags
85
+ ❌ FINAL RESPONSE must end with 'finish' suffix to indicate completion
86
+
87
+ TOOL CALL FORMAT::
88
+
89
+ TOOL: <tool_name>
90
+ OPERATION: <operation_name>
91
+ PARAMETERS: <json_parameters>
92
+
93
+ FINAL RESPONSE FORMAT::
94
+
95
+ FINAL RESPONSE: <your_response> finish
96
+
97
+ **Important Notes for Callers:**
98
+
99
+ - FINAL RESPONSE MUST end with 'finish' to indicate completion
100
+ - If no 'finish' suffix, the system assumes response is incomplete and will continue iteration
101
+ - LLM can output JSON or any text format - it will be passed through unchanged
102
+ - Each iteration will inform LLM of current iteration number and remaining iterations
103
+ - If LLM generation is incomplete, it will be asked to continue from where it left off
104
+ - Callers can customize max_iterations to control loop behavior
105
+ - Callers are responsible for parsing and handling LLM output format
106
+
46
107
  Examples:
47
108
  # Example 1: Basic usage with tool names (backward compatible)
48
109
  agent = HybridAgent(
@@ -339,17 +400,29 @@ class HybridAgent(BaseAIAgent):
339
400
 
340
401
  # Add ReAct instructions (always required for HybridAgent)
341
402
  parts.append(
342
- "You are a reasoning agent that can use tools to complete tasks. "
343
- "Follow the ReAct pattern:\n"
403
+ "Within the given identity framework, you are also a highly intelligent, responsive, and accurate reasoning agent. that can use tools to complete tasks. "
404
+ "Follow the ReAct (Reasoning + Acting) pattern to achieve best results:\n"
344
405
  "1. THOUGHT: Analyze the task and decide what to do\n"
345
406
  "2. ACTION: Use a tool if needed, or provide final answer\n"
346
407
  "3. OBSERVATION: Review the tool result and continue reasoning\n\n"
347
- "When you need to use a tool, respond with:\n"
348
- "TOOL: <tool_name>\n"
349
- "OPERATION: <operation_name>\n"
350
- "PARAMETERS: <json_parameters>\n\n"
351
- "When you have the final answer, respond with:\n"
352
- "FINAL ANSWER: <your_answer>"
408
+ "RESPONSE FORMAT REQUIREMENTS:\n"
409
+ "- Wrap your thinking process in <THOUGHT>...</THOUGHT> tags\n"
410
+ "- Wrap your insight about tool result in <OBSERVATION>...</OBSERVATION> tags\n"
411
+ "- Tool calls (TOOL:, OPERATION:, PARAMETERS:) MUST be OUTSIDE <THOUGHT> and <OBSERVATION> tags\n"
412
+ "- Final responses (FINAL RESPONSE:) MUST be OUTSIDE <THOUGHT> and <OBSERVATION> tags\n\n"
413
+ "THINKING GUIDANCE:\n"
414
+ "When writing <THOUGHT> sections, consider:\n"
415
+ "- What is the core thing to do?\n"
416
+ "- What information do I already have?\n"
417
+ "- What information do I need to gather?\n"
418
+ "- Which tools would be most helpful?\n"
419
+ "- What action should I take?\n\n"
420
+ "OBSERVATION GUIDANCE:\n"
421
+ "When writing <OBSERVATION> sections, consider:\n"
422
+ "- What did I learn from the tool results?\n"
423
+ "- How does this information inform my next work?\n"
424
+ "- Do I need additional information?\n"
425
+ "- Am I ready to provide a final response?"
353
426
  )
354
427
 
355
428
  # Add available tools (always required for HybridAgent)
@@ -408,7 +481,7 @@ class HybridAgent(BaseAIAgent):
408
481
 
409
482
  return {
410
483
  "success": True,
411
- "output": result.get("final_answer"),
484
+ "output": result.get("final_response"), # Changed from final_answer
412
485
  "reasoning_steps": result.get("steps"),
413
486
  "tool_calls_count": result.get("tool_calls_count"),
414
487
  "iterations": result.get("iterations"),
@@ -605,11 +678,23 @@ class HybridAgent(BaseAIAgent):
605
678
  for iteration in range(self._max_iterations):
606
679
  logger.debug(f"HybridAgent {self.agent_id} - ReAct iteration {iteration + 1}")
607
680
 
681
+ # Add iteration info to messages (except first iteration which has task context)
682
+ if iteration > 0:
683
+ iteration_info = (
684
+ f"[Iteration {iteration + 1}/{self._max_iterations}, "
685
+ f"remaining: {self._max_iterations - iteration - 1}]"
686
+ )
687
+ # Only add if the last message is not already an iteration info
688
+ if messages and not messages[-1].content.startswith("[Iteration"):
689
+ messages.append(LLMMessage(role="user", content=iteration_info))
690
+
608
691
  # Yield iteration status
609
692
  yield {
610
693
  "type": "status",
611
694
  "status": "thinking",
612
695
  "iteration": iteration + 1,
696
+ "max_iterations": self._max_iterations,
697
+ "remaining": self._max_iterations - iteration - 1,
613
698
  "timestamp": datetime.utcnow().isoformat(),
614
699
  }
615
700
 
@@ -677,7 +762,16 @@ class HybridAgent(BaseAIAgent):
677
762
  "timestamp": datetime.utcnow().isoformat(),
678
763
  }
679
764
 
680
- thought = "".join(thought_tokens)
765
+ thought_raw = "".join(thought_tokens)
766
+
767
+ # Store raw output in steps (no format processing)
768
+ steps.append(
769
+ {
770
+ "type": "thought",
771
+ "content": thought_raw.strip(), # Return raw output without processing
772
+ "iteration": iteration + 1,
773
+ }
774
+ )
681
775
 
682
776
  # Process tool_calls if received from stream
683
777
  if tool_calls_from_stream:
@@ -688,19 +782,30 @@ class HybridAgent(BaseAIAgent):
688
782
  func_args = tool_call["function"]["arguments"]
689
783
 
690
784
  # Parse function name to extract tool and operation
691
- parts = func_name.split("_", 1)
692
- if len(parts) == 2:
693
- tool_name, operation = parts
694
- else:
695
- tool_name = parts[0]
785
+ # CRITICAL: Try exact match first, then fall back to underscore parsing
786
+ if self._tool_instances and func_name in self._tool_instances:
787
+ # Exact match found - use full function name as tool name
788
+ tool_name = func_name
789
+ operation = None
790
+ elif self._available_tools and func_name in self._available_tools:
791
+ # Exact match in available tools list
792
+ tool_name = func_name
696
793
  operation = None
794
+ else:
795
+ # Fallback: try underscore parsing for legacy compatibility
796
+ parts = func_name.split("_", 1)
797
+ if len(parts) == 2:
798
+ tool_name, operation = parts
799
+ else:
800
+ tool_name = parts[0]
801
+ operation = None
697
802
 
698
803
  # Parse arguments JSON
699
804
  import json
700
805
  if isinstance(func_args, str):
701
806
  parameters = json.loads(func_args)
702
807
  else:
703
- parameters = func_args
808
+ parameters = func_args if func_args else {}
704
809
 
705
810
  # Yield tool call event
706
811
  yield {
@@ -715,17 +820,19 @@ class HybridAgent(BaseAIAgent):
715
820
  tool_result = await self._execute_tool(tool_name, operation, parameters)
716
821
  tool_calls_count += 1
717
822
 
823
+ # Wrap tool call and result in step
718
824
  steps.append(
719
825
  {
720
826
  "type": "action",
721
827
  "tool": tool_name,
722
828
  "operation": operation,
723
829
  "parameters": parameters,
830
+ "result": str(tool_result), # Include result in step
724
831
  "iteration": iteration + 1,
725
832
  }
726
833
  )
727
834
 
728
- # Yield tool result event
835
+ # Yield tool result event (streaming)
729
836
  yield {
730
837
  "type": "tool_result",
731
838
  "tool_name": tool_name,
@@ -733,15 +840,9 @@ class HybridAgent(BaseAIAgent):
733
840
  "timestamp": datetime.utcnow().isoformat(),
734
841
  }
735
842
 
736
- # Add tool result to messages
737
- observation = f"Tool '{tool_name}' returned: {tool_result}"
738
- steps.append(
739
- {
740
- "type": "observation",
741
- "content": observation,
742
- "iteration": iteration + 1,
743
- }
744
- )
843
+ # Add tool result to messages (for LLM consumption)
844
+ observation_content = f"Tool '{tool_name}' returned: {tool_result}"
845
+ observation = f"<OBSERVATION>\n{observation_content}\n</OBSERVATION>"
745
846
 
746
847
  # Add assistant message with tool call and tool result
747
848
  messages.append(
@@ -760,13 +861,14 @@ class HybridAgent(BaseAIAgent):
760
861
  )
761
862
 
762
863
  except Exception as e:
763
- error_msg = f"Tool execution failed: {str(e)}"
864
+ error_content = f"Tool execution failed: {str(e)}"
865
+ error_msg = f"<OBSERVATION>\n{error_content}\n</OBSERVATION>"
764
866
  steps.append(
765
867
  {
766
868
  "type": "observation",
767
869
  "content": error_msg,
768
870
  "iteration": iteration + 1,
769
- "error": True,
871
+ "has_error": True,
770
872
  }
771
873
  )
772
874
  yield {
@@ -786,21 +888,13 @@ class HybridAgent(BaseAIAgent):
786
888
  # Continue to next iteration
787
889
  continue
788
890
 
789
- steps.append(
790
- {
791
- "type": "thought",
792
- "content": thought,
793
- "iteration": iteration + 1,
794
- }
795
- )
796
-
797
- # Check if final answer
798
- if "FINAL ANSWER:" in thought:
799
- final_answer = self._extract_final_answer(thought)
891
+ # Check for final response (outside tags only)
892
+ if self._has_final_response(thought_raw):
893
+ final_response = self._extract_final_response(thought_raw)
800
894
  yield {
801
895
  "type": "result",
802
896
  "success": True,
803
- "output": final_answer,
897
+ "output": final_response, # Return raw output without processing
804
898
  "reasoning_steps": steps,
805
899
  "tool_calls_count": tool_calls_count,
806
900
  "iterations": iteration + 1,
@@ -809,11 +903,11 @@ class HybridAgent(BaseAIAgent):
809
903
  }
810
904
  return
811
905
 
812
- # Check if tool call
813
- if "TOOL:" in thought:
906
+ # Check if tool call (ReAct mode, outside tags only)
907
+ if self._has_tool_call(thought_raw):
814
908
  # ACT: Execute tool
815
909
  try:
816
- tool_info = self._parse_tool_call(thought)
910
+ tool_info = self._parse_tool_call(thought_raw) # Parse from raw text
817
911
  tool_name = tool_info.get("tool", "")
818
912
  if not tool_name:
819
913
  raise ValueError("Tool name not found in tool call")
@@ -834,27 +928,19 @@ class HybridAgent(BaseAIAgent):
834
928
  )
835
929
  tool_calls_count += 1
836
930
 
931
+ # Wrap tool call and result in step
837
932
  steps.append(
838
933
  {
839
934
  "type": "action",
840
935
  "tool": tool_info["tool"],
841
936
  "operation": tool_info.get("operation"),
842
937
  "parameters": tool_info.get("parameters"),
938
+ "result": str(tool_result), # Include result in step
843
939
  "iteration": iteration + 1,
844
940
  }
845
941
  )
846
942
 
847
- # OBSERVE: Add tool result to conversation
848
- observation = f"OBSERVATION: Tool '{tool_info['tool']}' returned: {tool_result}"
849
- steps.append(
850
- {
851
- "type": "observation",
852
- "content": observation,
853
- "iteration": iteration + 1,
854
- }
855
- )
856
-
857
- # Yield tool result event
943
+ # Yield tool result event (streaming)
858
944
  yield {
859
945
  "type": "tool_result",
860
946
  "tool_name": tool_name,
@@ -862,16 +948,22 @@ class HybridAgent(BaseAIAgent):
862
948
  "timestamp": datetime.utcnow().isoformat(),
863
949
  }
864
950
 
951
+ # OBSERVE: Add tool result to conversation (for LLM consumption)
952
+ observation_content = f"Tool '{tool_info['tool']}' returned: {tool_result}"
953
+ observation = f"<OBSERVATION>\n{observation_content}\n</OBSERVATION>"
954
+
865
955
  # Add to messages for next iteration
866
- messages.append(LLMMessage(role="assistant", content=thought))
956
+ messages.append(LLMMessage(role="assistant", content=thought_raw))
867
957
  messages.append(LLMMessage(role="user", content=observation))
868
958
 
869
959
  except Exception as e:
870
- error_msg = f"OBSERVATION: Tool execution failed: {str(e)}"
960
+ error_content = f"Tool execution failed: {str(e)}"
961
+ error_msg = f"<OBSERVATION>\n{error_content}\n</OBSERVATION>"
871
962
  steps.append(
872
963
  {
873
- "type": "observation",
874
- "content": error_msg,
964
+ "type": "action",
965
+ "tool": tool_name if "tool_name" in locals() else "unknown",
966
+ "error": str(e),
875
967
  "iteration": iteration + 1,
876
968
  "error": True,
877
969
  }
@@ -885,22 +977,37 @@ class HybridAgent(BaseAIAgent):
885
977
  "timestamp": datetime.utcnow().isoformat(),
886
978
  }
887
979
 
888
- messages.append(LLMMessage(role="assistant", content=thought))
980
+ messages.append(LLMMessage(role="assistant", content=thought_raw))
889
981
  messages.append(LLMMessage(role="user", content=error_msg))
890
982
 
891
983
  else:
892
- # LLM didn't provide clear action - treat as final answer
893
- yield {
894
- "type": "result",
895
- "success": True,
896
- "output": thought,
897
- "reasoning_steps": steps,
898
- "tool_calls_count": tool_calls_count,
899
- "iterations": iteration + 1,
900
- "total_tokens": total_tokens,
901
- "timestamp": datetime.utcnow().isoformat(),
902
- }
903
- return
984
+ # Check if there's an incomplete final response (has FINAL RESPONSE but no finish)
985
+ if self._has_incomplete_final_response(thought_raw):
986
+ # Incomplete final response - ask LLM to continue
987
+ continue_message = (
988
+ f"[Iteration {iteration + 1}/{self._max_iterations}, "
989
+ f"remaining: {self._max_iterations - iteration - 1}]\n"
990
+ "Your FINAL RESPONSE appears incomplete (missing 'finish' suffix). "
991
+ "Please continue your response from where you left off and end with 'finish' "
992
+ "to indicate completion. If no 'finish' suffix, the system will continue iteration."
993
+ )
994
+ messages.append(LLMMessage(role="assistant", content=thought_raw))
995
+ messages.append(LLMMessage(role="user", content=continue_message))
996
+ else:
997
+ # No tool call or final response detected - ask LLM to continue
998
+ continue_message = (
999
+ f"[Iteration {iteration + 1}/{self._max_iterations}, "
1000
+ f"remaining: {self._max_iterations - iteration - 1}]\n"
1001
+ "Continuing from your previous output. "
1002
+ "If your generation is incomplete, please continue from where you left off. "
1003
+ "If you decide to take action, ensure proper format:\n"
1004
+ "- Tool call: TOOL:, OPERATION:, PARAMETERS: (outside tags)\n"
1005
+ "- Final response: FINAL RESPONSE: <content> finish (outside tags)"
1006
+ )
1007
+ messages.append(LLMMessage(role="assistant", content=thought_raw))
1008
+ messages.append(LLMMessage(role="user", content=continue_message))
1009
+ # Continue to next iteration
1010
+ continue
904
1011
 
905
1012
  # Max iterations reached
906
1013
  logger.warning(f"HybridAgent {self.agent_id} reached max iterations")
@@ -937,6 +1044,16 @@ class HybridAgent(BaseAIAgent):
937
1044
  for iteration in range(self._max_iterations):
938
1045
  logger.debug(f"HybridAgent {self.agent_id} - ReAct iteration {iteration + 1}")
939
1046
 
1047
+ # Add iteration info to messages (except first iteration which has task context)
1048
+ if iteration > 0:
1049
+ iteration_info = (
1050
+ f"[Iteration {iteration + 1}/{self._max_iterations}, "
1051
+ f"remaining: {self._max_iterations - iteration - 1}]"
1052
+ )
1053
+ # Only add if the last message is not already an iteration info
1054
+ if messages and not messages[-1].content.startswith("[Iteration"):
1055
+ messages.append(LLMMessage(role="user", content=iteration_info))
1056
+
940
1057
  # THINK: LLM reasons about next action
941
1058
  # Use Function Calling if supported, otherwise use ReAct mode
942
1059
  if self._use_function_calling and self._tool_schemas:
@@ -959,9 +1076,29 @@ class HybridAgent(BaseAIAgent):
959
1076
  max_tokens=self._config.max_tokens,
960
1077
  )
961
1078
 
962
- thought = response.content or ""
1079
+ thought_raw = response.content or ""
963
1080
  total_tokens += getattr(response, "total_tokens", 0)
964
1081
 
1082
+ # Update prompt cache metrics from LLM response
1083
+ cache_read_tokens = getattr(response, "cache_read_tokens", None)
1084
+ cache_creation_tokens = getattr(response, "cache_creation_tokens", None)
1085
+ cache_hit = getattr(response, "cache_hit", None)
1086
+ if cache_read_tokens is not None or cache_creation_tokens is not None or cache_hit is not None:
1087
+ self.update_cache_metrics(
1088
+ cache_read_tokens=cache_read_tokens,
1089
+ cache_creation_tokens=cache_creation_tokens,
1090
+ cache_hit=cache_hit,
1091
+ )
1092
+
1093
+ # Store raw output in steps (no format processing)
1094
+ steps.append(
1095
+ {
1096
+ "type": "thought",
1097
+ "content": thought_raw.strip(), # Return raw output without processing
1098
+ "iteration": iteration + 1,
1099
+ }
1100
+ )
1101
+
965
1102
  # Check for Function Calling response
966
1103
  tool_calls = getattr(response, "tool_calls", None)
967
1104
  function_call = getattr(response, "function_call", None)
@@ -989,52 +1126,50 @@ class HybridAgent(BaseAIAgent):
989
1126
  func_args = tool_call["function"]["arguments"]
990
1127
 
991
1128
  # Parse function name to extract tool and operation
992
- # Format: tool_name_operation or tool_name
993
- parts = func_name.split("_", 1)
994
- if len(parts) == 2:
995
- tool_name, operation = parts
996
- else:
997
- tool_name = parts[0]
1129
+ # CRITICAL: Try exact match first, then fall back to underscore parsing
1130
+ if self._tool_instances and func_name in self._tool_instances:
1131
+ # Exact match found - use full function name as tool name
1132
+ tool_name = func_name
1133
+ operation = None
1134
+ elif self._available_tools and func_name in self._available_tools:
1135
+ # Exact match in available tools list
1136
+ tool_name = func_name
998
1137
  operation = None
1138
+ else:
1139
+ # Fallback: try underscore parsing for legacy compatibility
1140
+ parts = func_name.split("_", 1)
1141
+ if len(parts) == 2:
1142
+ tool_name, operation = parts
1143
+ else:
1144
+ tool_name = parts[0]
1145
+ operation = None
999
1146
 
1000
1147
  # Parse arguments JSON
1001
1148
  import json
1002
1149
  if isinstance(func_args, str):
1003
1150
  parameters = json.loads(func_args)
1004
1151
  else:
1005
- parameters = func_args
1006
-
1007
- steps.append(
1008
- {
1009
- "type": "thought",
1010
- "content": f"Calling tool {func_name}",
1011
- "iteration": iteration + 1,
1012
- }
1013
- )
1152
+ parameters = func_args if func_args else {}
1014
1153
 
1015
1154
  # Execute tool
1016
1155
  tool_result = await self._execute_tool(tool_name, operation, parameters)
1017
1156
  tool_calls_count += 1
1018
1157
 
1158
+ # Wrap tool call and result in step
1019
1159
  steps.append(
1020
1160
  {
1021
1161
  "type": "action",
1022
1162
  "tool": tool_name,
1023
1163
  "operation": operation,
1024
1164
  "parameters": parameters,
1165
+ "result": str(tool_result), # Include result in step
1025
1166
  "iteration": iteration + 1,
1026
1167
  }
1027
1168
  )
1028
1169
 
1029
- # Add tool result to messages
1030
- observation = f"Tool '{tool_name}' returned: {tool_result}"
1031
- steps.append(
1032
- {
1033
- "type": "observation",
1034
- "content": observation,
1035
- "iteration": iteration + 1,
1036
- }
1037
- )
1170
+ # Add tool result to messages (for LLM consumption)
1171
+ observation_content = f"Tool '{tool_name}' returned: {tool_result}"
1172
+ observation = f"<OBSERVATION>\n{observation_content}\n</OBSERVATION>"
1038
1173
 
1039
1174
  # Add assistant message with tool call and tool result
1040
1175
  messages.append(
@@ -1053,13 +1188,14 @@ class HybridAgent(BaseAIAgent):
1053
1188
  )
1054
1189
 
1055
1190
  except Exception as e:
1056
- error_msg = f"Tool execution failed: {str(e)}"
1191
+ error_content = f"Tool execution failed: {str(e)}"
1192
+ error_msg = f"<OBSERVATION>\n{error_content}\n</OBSERVATION>"
1057
1193
  steps.append(
1058
1194
  {
1059
1195
  "type": "observation",
1060
1196
  "content": error_msg,
1061
1197
  "iteration": iteration + 1,
1062
- "error": True,
1198
+ "has_error": True,
1063
1199
  }
1064
1200
  )
1065
1201
  # Add error to messages
@@ -1074,41 +1210,22 @@ class HybridAgent(BaseAIAgent):
1074
1210
  # Continue to next iteration
1075
1211
  continue
1076
1212
 
1077
- # If using Function Calling and no tool calls, check if we have a final answer
1078
- if self._use_function_calling and thought:
1079
- # LLM provided a text response without tool calls - treat as final answer
1213
+ # Check for final response (outside tags only)
1214
+ if self._has_final_response(thought_raw):
1215
+ final_response = self._extract_final_response(thought_raw)
1080
1216
  return {
1081
- "final_answer": thought,
1217
+ "final_response": final_response, # Return raw output without processing
1082
1218
  "steps": steps,
1083
1219
  "iterations": iteration + 1,
1084
1220
  "tool_calls_count": tool_calls_count,
1085
1221
  "total_tokens": total_tokens,
1086
1222
  }
1087
1223
 
1088
- steps.append(
1089
- {
1090
- "type": "thought",
1091
- "content": thought,
1092
- "iteration": iteration + 1,
1093
- }
1094
- )
1095
-
1096
- # Check if final answer (ReAct mode)
1097
- if "FINAL ANSWER:" in thought:
1098
- final_answer = self._extract_final_answer(thought)
1099
- return {
1100
- "final_answer": final_answer,
1101
- "steps": steps,
1102
- "iterations": iteration + 1,
1103
- "tool_calls_count": tool_calls_count,
1104
- "total_tokens": total_tokens,
1105
- }
1106
-
1107
- # Check if tool call (ReAct mode)
1108
- if "TOOL:" in thought:
1224
+ # Check if tool call (ReAct mode, outside tags only)
1225
+ if self._has_tool_call(thought_raw):
1109
1226
  # ACT: Execute tool
1110
1227
  try:
1111
- tool_info = self._parse_tool_call(thought)
1228
+ tool_info = self._parse_tool_call(thought_raw) # Parse from raw text
1112
1229
  tool_name = tool_info.get("tool", "")
1113
1230
  if not tool_name:
1114
1231
  raise ValueError("Tool name not found in tool call")
@@ -1119,57 +1236,74 @@ class HybridAgent(BaseAIAgent):
1119
1236
  )
1120
1237
  tool_calls_count += 1
1121
1238
 
1239
+ # Wrap tool call and result in step
1122
1240
  steps.append(
1123
1241
  {
1124
1242
  "type": "action",
1125
1243
  "tool": tool_info["tool"],
1126
1244
  "operation": tool_info.get("operation"),
1127
1245
  "parameters": tool_info.get("parameters"),
1246
+ "result": str(tool_result), # Include result in step
1128
1247
  "iteration": iteration + 1,
1129
1248
  }
1130
1249
  )
1131
1250
 
1132
- # OBSERVE: Add tool result to conversation
1133
- observation = f"OBSERVATION: Tool '{tool_info['tool']}' returned: {tool_result}"
1134
- steps.append(
1135
- {
1136
- "type": "observation",
1137
- "content": observation,
1138
- "iteration": iteration + 1,
1139
- }
1140
- )
1251
+ # OBSERVE: Add tool result to conversation (for LLM consumption)
1252
+ observation_content = f"Tool '{tool_info['tool']}' returned: {tool_result}"
1253
+ observation = f"<OBSERVATION>\n{observation_content}\n</OBSERVATION>"
1141
1254
 
1142
1255
  # Add to messages for next iteration
1143
- messages.append(LLMMessage(role="assistant", content=thought))
1256
+ messages.append(LLMMessage(role="assistant", content=thought_raw))
1144
1257
  messages.append(LLMMessage(role="user", content=observation))
1145
1258
 
1146
1259
  except Exception as e:
1147
- error_msg = f"OBSERVATION: Tool execution failed: {str(e)}"
1260
+ error_content = f"Tool execution failed: {str(e)}"
1261
+ error_msg = f"<OBSERVATION>\n{error_content}\n</OBSERVATION>"
1148
1262
  steps.append(
1149
1263
  {
1150
- "type": "observation",
1151
- "content": error_msg,
1264
+ "type": "action",
1265
+ "tool": tool_name if "tool_name" in locals() else "unknown",
1266
+ "error": str(e),
1152
1267
  "iteration": iteration + 1,
1153
- "error": True,
1268
+ "has_error": True,
1154
1269
  }
1155
1270
  )
1156
- messages.append(LLMMessage(role="assistant", content=thought))
1271
+ messages.append(LLMMessage(role="assistant", content=thought_raw))
1157
1272
  messages.append(LLMMessage(role="user", content=error_msg))
1158
1273
 
1159
1274
  else:
1160
- # LLM didn't provide clear action - treat as final answer
1161
- return {
1162
- "final_answer": thought,
1163
- "steps": steps,
1164
- "iterations": iteration + 1,
1165
- "tool_calls_count": tool_calls_count,
1166
- "total_tokens": total_tokens,
1167
- }
1275
+ # Check if there's an incomplete final response (has FINAL RESPONSE but no finish)
1276
+ if self._has_incomplete_final_response(thought_raw):
1277
+ # Incomplete final response - ask LLM to continue
1278
+ continue_message = (
1279
+ f"[Iteration {iteration + 1}/{self._max_iterations}, "
1280
+ f"remaining: {self._max_iterations - iteration - 1}]\n"
1281
+ "Your FINAL RESPONSE appears incomplete (missing 'finish' suffix). "
1282
+ "Please continue your response from where you left off and end with 'finish' "
1283
+ "to indicate completion. If no 'finish' suffix, the system will continue iteration."
1284
+ )
1285
+ messages.append(LLMMessage(role="assistant", content=thought_raw))
1286
+ messages.append(LLMMessage(role="user", content=continue_message))
1287
+ else:
1288
+ # No tool call or final response detected - ask LLM to continue
1289
+ continue_message = (
1290
+ f"[Iteration {iteration + 1}/{self._max_iterations}, "
1291
+ f"remaining: {self._max_iterations - iteration - 1}]\n"
1292
+ "Continuing from your previous output. "
1293
+ "If your generation is incomplete, please continue from where you left off. "
1294
+ "If you decide to take action, ensure proper format:\n"
1295
+ "- Tool call: TOOL:, OPERATION:, PARAMETERS: (outside tags)\n"
1296
+ "- Final response: FINAL RESPONSE: <content> finish (outside tags)"
1297
+ )
1298
+ messages.append(LLMMessage(role="assistant", content=thought_raw))
1299
+ messages.append(LLMMessage(role="user", content=continue_message))
1300
+ # Continue to next iteration
1301
+ continue
1168
1302
 
1169
1303
  # Max iterations reached
1170
1304
  logger.warning(f"HybridAgent {self.agent_id} reached max iterations")
1171
1305
  return {
1172
- "final_answer": "Max iterations reached. Unable to complete task fully.",
1306
+ "final_response": "Max iterations reached. Unable to complete task fully.",
1173
1307
  "steps": steps,
1174
1308
  "iterations": self._max_iterations,
1175
1309
  "tool_calls_count": tool_calls_count,
@@ -1198,17 +1332,42 @@ class HybridAgent(BaseAIAgent):
1198
1332
 
1199
1333
  # Add context if provided
1200
1334
  if context:
1201
- context_str = self._format_context(context)
1202
- if context_str:
1203
- messages.append(
1204
- LLMMessage(
1205
- role="system",
1206
- content=f"Additional Context:\n{context_str}",
1335
+ # Special handling: if context contains 'history' as a list of messages,
1336
+ # add them as separate user/assistant messages instead of formatting
1337
+ history = context.get("history")
1338
+ if isinstance(history, list) and len(history) > 0:
1339
+ # Check if history contains message-like dictionaries
1340
+ for msg in history:
1341
+ if isinstance(msg, dict) and "role" in msg and "content" in msg:
1342
+ # Valid message format - add as separate message
1343
+ messages.append(
1344
+ LLMMessage(
1345
+ role=msg["role"],
1346
+ content=msg["content"],
1347
+ )
1348
+ )
1349
+ elif isinstance(msg, LLMMessage):
1350
+ # Already an LLMMessage instance
1351
+ messages.append(msg)
1352
+
1353
+ # Format remaining context fields (excluding history) as Additional Context
1354
+ context_without_history = {k: v for k, v in context.items() if k != "history"}
1355
+ if context_without_history:
1356
+ context_str = self._format_context(context_without_history)
1357
+ if context_str:
1358
+ messages.append(
1359
+ LLMMessage(
1360
+ role="user",
1361
+ content=f"Additional Context:\n{context_str}",
1362
+ )
1207
1363
  )
1208
- )
1209
1364
 
1210
- # Add task
1211
- messages.append(LLMMessage(role="user", content=f"Task: {task}"))
1365
+ # Add task with iteration info
1366
+ task_message = (
1367
+ f"Task: {task}\n\n"
1368
+ f"[Iteration 1/{self._max_iterations}, remaining: {self._max_iterations - 1}]"
1369
+ )
1370
+ messages.append(LLMMessage(role="user", content=task_message))
1212
1371
 
1213
1372
  return messages
1214
1373
 
@@ -1220,15 +1379,147 @@ class HybridAgent(BaseAIAgent):
1220
1379
  relevant_fields.append(f"{key}: {value}")
1221
1380
  return "\n".join(relevant_fields) if relevant_fields else ""
1222
1381
 
1223
- def _extract_final_answer(self, thought: str) -> str:
1224
- """Extract final answer from thought."""
1225
- if "FINAL ANSWER:" in thought:
1226
- return thought.split("FINAL ANSWER:", 1)[1].strip()
1227
- return thought
1382
+ def _extract_thought_content(self, text: str) -> str:
1383
+ """
1384
+ Extract content from <THOUGHT>...</THOUGHT> tags.
1385
+
1386
+ DEPRECATED: This method is kept for backward compatibility but no longer
1387
+ extracts content. Returns original text as-is per new design.
1388
+
1389
+ Args:
1390
+ text: Text that may contain THOUGHT tags
1391
+
1392
+ Returns:
1393
+ Original text (no extraction performed)
1394
+ """
1395
+ # Return original text without processing (new design)
1396
+ return text.strip()
1397
+
1398
+ def _extract_observation_content(self, text: str) -> str:
1399
+ """
1400
+ Extract content from <OBSERVATION>...</OBSERVATION> tags.
1401
+
1402
+ DEPRECATED: This method is kept for backward compatibility but no longer
1403
+ extracts content. Returns original text as-is per new design.
1404
+
1405
+ Args:
1406
+ text: Text that may contain OBSERVATION tags
1407
+
1408
+ Returns:
1409
+ Original text (no extraction performed)
1410
+ """
1411
+ # Return original text without processing (new design)
1412
+ return text.strip()
1228
1413
 
1229
- def _parse_tool_call(self, thought: str) -> Dict[str, Any]:
1414
+ def _has_final_response(self, text: str) -> bool:
1415
+ """
1416
+ Check if text contains complete FINAL RESPONSE with 'finish' suffix.
1417
+
1418
+ The FINAL RESPONSE must end with 'finish' to be considered complete.
1419
+ If FINAL RESPONSE is present but without 'finish', it's considered incomplete
1420
+ and the loop will continue to let LLM complete the response.
1421
+
1422
+ Args:
1423
+ text: Text to check
1424
+
1425
+ Returns:
1426
+ True if complete FINAL RESPONSE (with finish suffix) found outside tags
1427
+ """
1428
+ import re
1429
+
1430
+ # Remove content inside THOUGHT and OBSERVATION tags
1431
+ text_without_tags = re.sub(r'<THOUGHT>.*?</THOUGHT>', '', text, flags=re.DOTALL)
1432
+ text_without_tags = re.sub(r'<OBSERVATION>.*?</OBSERVATION>', '', text_without_tags, flags=re.DOTALL)
1433
+
1434
+ # Check for FINAL RESPONSE marker with 'finish' suffix in remaining text
1435
+ # The 'finish' must appear after FINAL RESPONSE: content
1436
+ if "FINAL RESPONSE:" not in text_without_tags:
1437
+ return False
1438
+
1439
+ # Check if 'finish' appears after FINAL RESPONSE:
1440
+ # Use case-insensitive search for 'finish' at the end
1441
+ text_lower = text_without_tags.lower()
1442
+ final_response_idx = text_lower.find("final response:")
1443
+ if final_response_idx == -1:
1444
+ return False
1445
+
1446
+ # Check if 'finish' appears after the FINAL RESPONSE marker
1447
+ remaining_text = text_without_tags[final_response_idx:]
1448
+ return "finish" in remaining_text.lower()
1449
+
1450
+ def _has_incomplete_final_response(self, text: str) -> bool:
1230
1451
  """
1231
- Parse tool call from LLM thought.
1452
+ Check if text contains FINAL RESPONSE marker but without 'finish' suffix.
1453
+
1454
+ Args:
1455
+ text: Text to check
1456
+
1457
+ Returns:
1458
+ True if FINAL RESPONSE marker found but without finish suffix
1459
+ """
1460
+ import re
1461
+
1462
+ # Remove content inside THOUGHT and OBSERVATION tags
1463
+ text_without_tags = re.sub(r'<THOUGHT>.*?</THOUGHT>', '', text, flags=re.DOTALL)
1464
+ text_without_tags = re.sub(r'<OBSERVATION>.*?</OBSERVATION>', '', text_without_tags, flags=re.DOTALL)
1465
+
1466
+ # Check for FINAL RESPONSE marker without 'finish' suffix
1467
+ if "FINAL RESPONSE:" not in text_without_tags:
1468
+ return False
1469
+
1470
+ # Check if 'finish' is missing
1471
+ text_lower = text_without_tags.lower()
1472
+ final_response_idx = text_lower.find("final response:")
1473
+ remaining_text = text_without_tags[final_response_idx:]
1474
+ return "finish" not in remaining_text.lower()
1475
+
1476
+ def _extract_final_response(self, text: str) -> str:
1477
+ """
1478
+ Extract final response from text, preserving original format.
1479
+ Only extracts from outside THOUGHT/OBSERVATION tags.
1480
+
1481
+ Args:
1482
+ text: Text that may contain FINAL RESPONSE marker
1483
+
1484
+ Returns:
1485
+ Original text if FINAL RESPONSE found, otherwise empty string
1486
+ """
1487
+ import re
1488
+
1489
+ # Remove content inside THOUGHT and OBSERVATION tags
1490
+ text_without_tags = re.sub(r'<THOUGHT>.*?</THOUGHT>', '', text, flags=re.DOTALL)
1491
+ text_without_tags = re.sub(r'<OBSERVATION>.*?</OBSERVATION>', '', text_without_tags, flags=re.DOTALL)
1492
+
1493
+ # Check for FINAL RESPONSE marker
1494
+ if "FINAL RESPONSE:" in text_without_tags:
1495
+ # Return original text without any processing
1496
+ return text.strip()
1497
+
1498
+ return ""
1499
+
1500
+ def _has_tool_call(self, text: str) -> bool:
1501
+ """
1502
+ Check if text contains TOOL call marker outside of THOUGHT/OBSERVATION tags.
1503
+
1504
+ Args:
1505
+ text: Text to check
1506
+
1507
+ Returns:
1508
+ True if TOOL marker found outside tags
1509
+ """
1510
+ import re
1511
+
1512
+ # Remove content inside THOUGHT and OBSERVATION tags
1513
+ text_without_tags = re.sub(r'<THOUGHT>.*?</THOUGHT>', '', text, flags=re.DOTALL)
1514
+ text_without_tags = re.sub(r'<OBSERVATION>.*?</OBSERVATION>', '', text_without_tags, flags=re.DOTALL)
1515
+
1516
+ # Check for TOOL marker in remaining text
1517
+ return "TOOL:" in text_without_tags
1518
+
1519
+ def _parse_tool_call(self, text: str) -> Dict[str, Any]:
1520
+ """
1521
+ Parse tool call from LLM output.
1522
+ Only parses from outside THOUGHT/OBSERVATION tags.
1232
1523
 
1233
1524
  Expected format:
1234
1525
  TOOL: <tool_name>
@@ -1236,28 +1527,33 @@ class HybridAgent(BaseAIAgent):
1236
1527
  PARAMETERS: <json_parameters>
1237
1528
 
1238
1529
  Args:
1239
- thought: LLM thought containing tool call
1530
+ text: LLM output that may contain tool call
1240
1531
 
1241
1532
  Returns:
1242
1533
  Dictionary with 'tool', 'operation', 'parameters'
1243
1534
  """
1244
1535
  import json
1536
+ import re
1245
1537
 
1246
1538
  result = {}
1539
+
1540
+ # Remove content inside THOUGHT and OBSERVATION tags
1541
+ text_without_tags = re.sub(r'<THOUGHT>.*?</THOUGHT>', '', text, flags=re.DOTALL)
1542
+ text_without_tags = re.sub(r'<OBSERVATION>.*?</OBSERVATION>', '', text_without_tags, flags=re.DOTALL)
1247
1543
 
1248
- # Extract tool
1249
- if "TOOL:" in thought:
1250
- tool_line = [line for line in thought.split("\n") if line.startswith("TOOL:")][0]
1544
+ # Extract tool from text outside tags
1545
+ if "TOOL:" in text_without_tags:
1546
+ tool_line = [line for line in text_without_tags.split("\n") if line.strip().startswith("TOOL:")][0]
1251
1547
  result["tool"] = tool_line.split("TOOL:", 1)[1].strip()
1252
1548
 
1253
1549
  # Extract operation (optional)
1254
- if "OPERATION:" in thought:
1255
- op_line = [line for line in thought.split("\n") if line.startswith("OPERATION:")][0]
1550
+ if "OPERATION:" in text_without_tags:
1551
+ op_line = [line for line in text_without_tags.split("\n") if line.strip().startswith("OPERATION:")][0]
1256
1552
  result["operation"] = op_line.split("OPERATION:", 1)[1].strip()
1257
1553
 
1258
1554
  # Extract parameters (optional)
1259
- if "PARAMETERS:" in thought:
1260
- param_line = [line for line in thought.split("\n") if line.startswith("PARAMETERS:")][0]
1555
+ if "PARAMETERS:" in text_without_tags:
1556
+ param_line = [line for line in text_without_tags.split("\n") if line.strip().startswith("PARAMETERS:")][0]
1261
1557
  param_str = param_line.split("PARAMETERS:", 1)[1].strip()
1262
1558
  try:
1263
1559
  result["parameters"] = json.loads(param_str)