amd-gaia 0.15.1__py3-none-any.whl → 0.15.3__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 (38) hide show
  1. {amd_gaia-0.15.1.dist-info → amd_gaia-0.15.3.dist-info}/METADATA +2 -2
  2. {amd_gaia-0.15.1.dist-info → amd_gaia-0.15.3.dist-info}/RECORD +38 -32
  3. {amd_gaia-0.15.1.dist-info → amd_gaia-0.15.3.dist-info}/WHEEL +1 -1
  4. gaia/agents/base/agent.py +317 -113
  5. gaia/agents/base/api_agent.py +0 -1
  6. gaia/agents/base/console.py +334 -9
  7. gaia/agents/base/tools.py +7 -2
  8. gaia/agents/blender/__init__.py +7 -0
  9. gaia/agents/blender/agent.py +7 -10
  10. gaia/agents/blender/core/view.py +2 -2
  11. gaia/agents/chat/agent.py +22 -48
  12. gaia/agents/chat/app.py +7 -0
  13. gaia/agents/chat/tools/rag_tools.py +23 -8
  14. gaia/agents/chat/tools/shell_tools.py +1 -0
  15. gaia/agents/code/prompts/code_patterns.py +2 -4
  16. gaia/agents/docker/agent.py +1 -0
  17. gaia/agents/emr/agent.py +3 -5
  18. gaia/agents/emr/cli.py +1 -1
  19. gaia/agents/emr/dashboard/server.py +2 -4
  20. gaia/agents/tools/__init__.py +11 -0
  21. gaia/agents/tools/file_tools.py +715 -0
  22. gaia/apps/llm/app.py +14 -3
  23. gaia/chat/app.py +2 -4
  24. gaia/cli.py +751 -333
  25. gaia/installer/__init__.py +23 -0
  26. gaia/installer/init_command.py +1605 -0
  27. gaia/installer/lemonade_installer.py +678 -0
  28. gaia/llm/__init__.py +2 -1
  29. gaia/llm/lemonade_client.py +427 -99
  30. gaia/llm/lemonade_manager.py +55 -11
  31. gaia/llm/providers/lemonade.py +21 -14
  32. gaia/rag/sdk.py +1 -1
  33. gaia/security.py +24 -4
  34. gaia/talk/app.py +2 -4
  35. gaia/version.py +2 -2
  36. {amd_gaia-0.15.1.dist-info → amd_gaia-0.15.3.dist-info}/entry_points.txt +0 -0
  37. {amd_gaia-0.15.1.dist-info → amd_gaia-0.15.3.dist-info}/licenses/LICENSE.md +0 -0
  38. {amd_gaia-0.15.1.dist-info → amd_gaia-0.15.3.dist-info}/top_level.txt +0 -0
gaia/agents/base/agent.py CHANGED
@@ -61,10 +61,6 @@ class Agent(abc.ABC):
61
61
  STATE_ERROR_RECOVERY = "ERROR_RECOVERY"
62
62
  STATE_COMPLETION = "COMPLETION"
63
63
 
64
- # Define tools that can execute directly without requiring a plan
65
- # Subclasses can override this to specify domain-specific simple tools
66
- SIMPLE_TOOLS = []
67
-
68
64
  def __init__(
69
65
  self,
70
66
  use_claude: bool = False,
@@ -72,7 +68,7 @@ class Agent(abc.ABC):
72
68
  claude_model: str = "claude-sonnet-4-20250514",
73
69
  base_url: Optional[str] = None,
74
70
  model_id: str = None,
75
- max_steps: int = 5,
71
+ max_steps: int = 20,
76
72
  debug_prompts: bool = False,
77
73
  show_prompts: bool = False,
78
74
  output_dir: str = None,
@@ -82,6 +78,7 @@ class Agent(abc.ABC):
82
78
  debug: bool = False,
83
79
  output_handler=None,
84
80
  max_plan_iterations: int = 3,
81
+ max_consecutive_repeats: int = 4,
85
82
  min_context_size: int = 32768,
86
83
  skip_lemonade: bool = False,
87
84
  ):
@@ -104,6 +101,7 @@ class Agent(abc.ABC):
104
101
  debug: If True, enables debug output for troubleshooting (default: False)
105
102
  output_handler: Custom OutputHandler for displaying agent output (default: None, creates console based on silent_mode)
106
103
  max_plan_iterations: Maximum number of plan-execute-replan cycles (default: 3, 0 = unlimited)
104
+ max_consecutive_repeats: Maximum consecutive identical tool calls before stopping (default: 4)
107
105
  min_context_size: Minimum context size required for this agent (default: 32768).
108
106
  skip_lemonade: If True, skip Lemonade server initialization (default: False).
109
107
  Use this when connecting to a different OpenAI-compatible backend.
@@ -124,6 +122,7 @@ class Agent(abc.ABC):
124
122
  self.debug = debug
125
123
  self.last_result = None # Store the most recent result
126
124
  self.max_plan_iterations = max_plan_iterations
125
+ self.max_consecutive_repeats = max_consecutive_repeats
127
126
  self._current_query: Optional[str] = (
128
127
  None # Store current query for error context
129
128
  )
@@ -158,17 +157,17 @@ class Agent(abc.ABC):
158
157
  self.console = self._create_console()
159
158
 
160
159
  # Initialize LLM client for local model
161
- self.system_prompt = self._get_system_prompt()
160
+ # Note: System prompt will be composed after _register_tools()
161
+ # This allows mixins to be initialized first (in subclass __init__)
162
162
 
163
163
  # Register tools for this agent
164
164
  self._register_tools()
165
165
 
166
- # Update system prompt with available tools and response format
167
- tools_description = self._format_tools_for_prompt()
168
- self.system_prompt += f"\n\n==== AVAILABLE TOOLS ====\n{tools_description}\n"
166
+ # Note: system_prompt is now a lazy @property that composes on first access
167
+ # Tool descriptions and response format are added in _compose_system_prompt()
169
168
 
170
- # Add JSON response format instructions (shared across all agents)
171
- self.system_prompt += """
169
+ # Store response format template for use in composition
170
+ self._response_format_template = """
172
171
  ==== RESPONSE FORMAT ====
173
172
  You must respond ONLY in valid JSON. No text before { or after }.
174
173
 
@@ -224,13 +223,127 @@ You must respond ONLY in valid JSON. No text before { or after }.
224
223
  if self.show_prompts:
225
224
  self.console.print_prompt(self.system_prompt, "Initial System Prompt")
226
225
 
227
- @abc.abstractmethod
226
+ def _get_mixin_prompts(self) -> list[str]:
227
+ """
228
+ Auto-collect system prompt fragments from inherited mixins.
229
+
230
+ Checks for mixin methods following the pattern: get_*_system_prompt()
231
+ Override this method to modify, reorder, or filter mixin prompts.
232
+
233
+ Returns:
234
+ List of prompt fragments from mixins (empty list if no mixins provide prompts)
235
+
236
+ Example:
237
+ def _get_mixin_prompts(self) -> list[str]:
238
+ prompts = super()._get_mixin_prompts()
239
+ # Modify SD prompt
240
+ if prompts:
241
+ prompts[0] = prompts[0].replace("whimsical", "serious")
242
+ return prompts
243
+ """
244
+ prompts = []
245
+
246
+ # Check for SD mixin prompts
247
+ if hasattr(self, "get_sd_system_prompt"):
248
+ fragment = self.get_sd_system_prompt()
249
+ if fragment:
250
+ prompts.append(fragment)
251
+
252
+ # Check for VLM mixin prompts
253
+ if hasattr(self, "get_vlm_system_prompt"):
254
+ fragment = self.get_vlm_system_prompt()
255
+ if fragment:
256
+ prompts.append(fragment)
257
+
258
+ # Add more mixin checks here as new prompt-providing mixins are created
259
+
260
+ return prompts
261
+
262
+ def _compose_system_prompt(self) -> str:
263
+ """
264
+ Compose final system prompt from mixin fragments + agent custom + tools + format.
265
+
266
+ Override this method for complete control over prompt composition order.
267
+
268
+ Returns:
269
+ Composed system prompt string
270
+
271
+ Example:
272
+ def _compose_system_prompt(self) -> str:
273
+ # Custom composition order
274
+ parts = [
275
+ "Base instructions first",
276
+ *self._get_mixin_prompts(),
277
+ self._get_system_prompt(),
278
+ ]
279
+ return "\n\n".join(p for p in parts if p)
280
+ """
281
+ parts = []
282
+
283
+ # Add mixin prompts first
284
+ parts.extend(self._get_mixin_prompts())
285
+
286
+ # Add agent-specific prompt
287
+ custom = self._get_system_prompt()
288
+ if custom:
289
+ parts.append(custom)
290
+
291
+ # Add tool descriptions (if tools registered)
292
+ if hasattr(self, "_format_tools_for_prompt"):
293
+ tools_description = self._format_tools_for_prompt()
294
+ if tools_description:
295
+ parts.append(f"==== AVAILABLE TOOLS ====\n{tools_description}")
296
+
297
+ # Add response format (if template set)
298
+ if hasattr(self, "_response_format_template"):
299
+ parts.append(self._response_format_template)
300
+
301
+ return "\n\n".join(p for p in parts if p)
302
+
303
+ @property
304
+ def system_prompt(self) -> str:
305
+ """
306
+ Lazy-loaded system prompt composed from mixins + agent custom.
307
+
308
+ Computed on first access to allow mixins to initialize in subclass __init__.
309
+
310
+ To see the prompt for debugging:
311
+ print(agent.system_prompt)
312
+ """
313
+ if not hasattr(self, "_system_prompt_cache"):
314
+ self._system_prompt_cache = self._compose_system_prompt()
315
+ return self._system_prompt_cache
316
+
317
+ @system_prompt.setter
318
+ def system_prompt(self, value: str):
319
+ """Allow setting system prompt (used when appending tool descriptions)."""
320
+ self._system_prompt_cache = value
321
+
228
322
  def _get_system_prompt(self) -> str:
229
323
  """
230
- Generate the system prompt for the agent.
231
- Subclasses must implement this to provide domain-specific prompts.
324
+ Return agent-specific system prompt additions.
325
+
326
+ Default implementation returns empty string (use only mixin prompts).
327
+ Override this method to add custom instructions.
328
+
329
+ When using mixins that provide prompts (e.g., SDToolsMixin):
330
+ - Return "" to use only mixin prompts (default behavior)
331
+ - Return custom instructions to append to mixin prompts
332
+ - Override _compose_system_prompt() for full control over composition
333
+
334
+ Returns:
335
+ Agent-specific system prompt (empty string by default)
336
+
337
+ Example:
338
+ # Use only mixin prompts (default)
339
+ def _get_system_prompt(self) -> str:
340
+ return ""
341
+
342
+ # Add custom instructions
343
+ def _get_system_prompt(self) -> str:
344
+ return "Always save metadata to logs"
232
345
  """
233
- raise NotImplementedError("Subclasses must implement _get_system_prompt")
346
+ return "" # Default: use only mixin prompts
234
347
 
235
348
  def _create_console(self):
236
349
  """
@@ -280,7 +393,7 @@ You must respond ONLY in valid JSON. No text before { or after }.
280
393
  self.console.print_header(f"🛠️ Registered Tools for {self.__class__.__name__}")
281
394
  self.console.print_separator()
282
395
 
283
- for name, tool_info in _TOOL_REGISTRY.items():
396
+ for name, tool_info in self.get_tools_info().items():
284
397
  # Format parameters
285
398
  params = []
286
399
  for param_name, param_info in tool_info["parameters"].items():
@@ -313,6 +426,14 @@ You must respond ONLY in valid JSON. No text before { or after }.
313
426
 
314
427
  return None
315
428
 
429
+ def get_tools_info(self) -> Dict[str, Any]:
430
+ """Get information about all registered tools."""
431
+ return _TOOL_REGISTRY
432
+
433
+ def get_tools(self) -> List[Dict[str, Any]]:
434
+ """Get a list of registered tools for the agent."""
435
+ return list(_TOOL_REGISTRY.values())
436
+
316
437
  def _extract_json_from_response(self, response: str) -> Optional[Dict[str, Any]]:
317
438
  """
318
439
  Apply multiple extraction strategies to find valid JSON in the response.
@@ -743,6 +864,106 @@ You must respond ONLY in valid JSON. No text before { or after }.
743
864
  # Valid conversational response - wrap it in expected format
744
865
  return {"thought": "", "goal": "", "answer": response.strip()}
745
866
 
867
+ def _resolve_plan_parameters(
868
+ self, tool_args: Any, step_results: List[Dict[str, Any]], _depth: int = 0
869
+ ) -> Any:
870
+ """
871
+ Recursively resolve placeholder references in tool arguments from previous step results.
872
+
873
+ Supports dynamic parameter substitution in multi-step plans:
874
+ - $PREV.field - Get field from previous step result
875
+ - $STEP_0.field - Get field from specific step result (0-indexed)
876
+
877
+ Args:
878
+ tool_args: Tool arguments that may contain placeholders
879
+ step_results: List of results from previously executed steps
880
+ _depth: Internal recursion depth counter (max 50 levels)
881
+
882
+ Returns:
883
+ Tool arguments with placeholders resolved to actual values
884
+
885
+ Examples:
886
+ >>> step_results = [{"image_path": "/path/to/img.png", "status": "success"}]
887
+ >>> tool_args = {"image_path": "$PREV.image_path", "style": "dramatic"}
888
+ >>> resolved = agent._resolve_plan_parameters(tool_args, step_results)
889
+ >>> resolved
890
+ {"image_path": "/path/to/img.png", "style": "dramatic"}
891
+
892
+ Backward Compatibility:
893
+ - If no placeholders exist, returns original tool_args unchanged
894
+ - If placeholder references invalid step/field, returns placeholder string unchanged
895
+
896
+ Limitations:
897
+ - Field names cannot contain dots (e.g., $PREV.user.name not supported - use $PREV.user_name)
898
+ - Maximum nesting depth of 50 levels to prevent stack overflow
899
+ - No type checking - resolved values are used as-is (tools should validate inputs)
900
+ """
901
+ # Prevent stack overflow from deeply nested structures
902
+ MAX_DEPTH = 50
903
+ if _depth > MAX_DEPTH:
904
+ logger.warning(
905
+ f"Maximum recursion depth ({MAX_DEPTH}) exceeded in parameter resolution, returning unchanged"
906
+ )
907
+ return tool_args
908
+
909
+ # Handle dict: recursively resolve each value
910
+ if isinstance(tool_args, dict):
911
+ return {
912
+ k: self._resolve_plan_parameters(v, step_results, _depth + 1)
913
+ for k, v in tool_args.items()
914
+ }
915
+
916
+ # Handle list: recursively resolve each item
917
+ elif isinstance(tool_args, list):
918
+ return [
919
+ self._resolve_plan_parameters(item, step_results, _depth + 1)
920
+ for item in tool_args
921
+ ]
922
+
923
+ # Handle string: check for placeholder patterns
924
+ elif isinstance(tool_args, str):
925
+ # Handle $PREV.field - get field from previous step
926
+ if tool_args.startswith("$PREV.") and step_results:
927
+ field = tool_args[6:] # Strip "$PREV."
928
+ prev_result = step_results[-1]
929
+ if isinstance(prev_result, dict) and field in prev_result:
930
+ resolved = prev_result[field]
931
+ logger.debug(
932
+ f"Resolved {tool_args} -> {resolved} from previous step result"
933
+ )
934
+ return resolved
935
+ else:
936
+ logger.warning(
937
+ f"Could not resolve {tool_args}: field '{field}' not found in previous result"
938
+ )
939
+ return tool_args # Return unchanged if field not found
940
+
941
+ # Handle $STEP_N.field - get field from specific step
942
+ match = re.match(r"\$STEP_(\d+)\.(.+)", tool_args)
943
+ if match and step_results:
944
+ step_idx = int(match.group(1))
945
+ field = match.group(2)
946
+ if 0 <= step_idx < len(step_results):
947
+ step_result = step_results[step_idx]
948
+ if isinstance(step_result, dict) and field in step_result:
949
+ resolved = step_result[field]
950
+ logger.debug(
951
+ f"Resolved {tool_args} -> {resolved} from step {step_idx} result"
952
+ )
953
+ return resolved
954
+ else:
955
+ logger.warning(
956
+ f"Could not resolve {tool_args}: field '{field}' not found in step {step_idx} result"
957
+ )
958
+ else:
959
+ logger.warning(
960
+ f"Could not resolve {tool_args}: step {step_idx} out of range (0-{len(step_results)-1})"
961
+ )
962
+ return tool_args # Return unchanged if reference invalid
963
+
964
+ # For all other types (int, float, bool, None), return unchanged
965
+ return tool_args
966
+
746
967
  def _execute_tool(self, tool_name: str, tool_args: Dict[str, Any]) -> Any:
747
968
  """
748
969
  Execute a tool by name with the provided arguments.
@@ -1133,9 +1354,10 @@ You must respond ONLY in valid JSON. No text before { or after }.
1133
1354
  steps_taken = 0
1134
1355
  final_answer = None
1135
1356
  error_count = 0
1136
- last_tool_call = None # Track the last tool call to prevent loops
1357
+ tool_call_history = [] # Track recent tool calls to detect loops (last 5 calls)
1137
1358
  last_error = None # Track the last error to handle it properly
1138
- previous_outputs = [] # Track previous tool outputs
1359
+ previous_outputs = [] # Track previous tool outputs (truncated for context)
1360
+ step_results = [] # Track full tool results for parameter substitution
1139
1361
 
1140
1362
  # Reset state management
1141
1363
  self.execution_state = self.STATE_PLANNING
@@ -1152,7 +1374,7 @@ You must respond ONLY in valid JSON. No text before { or after }.
1152
1374
  steps_limit = max_steps if max_steps is not None else self.max_steps
1153
1375
 
1154
1376
  # Print initial message with max steps info
1155
- self.console.print_processing_start(user_input, steps_limit)
1377
+ self.console.print_processing_start(user_input, steps_limit, self.model_id)
1156
1378
  logger.debug(f"Using max_steps: {steps_limit}")
1157
1379
 
1158
1380
  prompt = f"User request: {user_input}\n\n"
@@ -1177,43 +1399,6 @@ You must respond ONLY in valid JSON. No text before { or after }.
1177
1399
  steps_taken += 1
1178
1400
  logger.debug(f"Step {steps_taken}/{steps_limit}")
1179
1401
 
1180
- # Check if we're at the limit and ask user if they want to continue
1181
- if steps_taken == steps_limit and final_answer is None:
1182
- # Show what was accomplished
1183
- max_steps_msg = self._generate_max_steps_message(
1184
- conversation, steps_taken, steps_limit
1185
- )
1186
- self.console.print_warning(max_steps_msg)
1187
-
1188
- # Ask user if they want to continue (skip in silent mode OR if stdin is not available)
1189
- # IMPORTANT: Never call input() in API/CI contexts to avoid blocking threads
1190
- import sys
1191
-
1192
- has_stdin = sys.stdin and sys.stdin.isatty()
1193
- if has_stdin and not (
1194
- hasattr(self, "silent_mode") and self.silent_mode
1195
- ):
1196
- try:
1197
- response = (
1198
- input("\nContinue with 50 more steps? (y/n): ")
1199
- .strip()
1200
- .lower()
1201
- )
1202
- if response in ["y", "yes"]:
1203
- steps_limit += 50
1204
- self.console.print_info(
1205
- f"✓ Continuing with {steps_limit} total steps...\n"
1206
- )
1207
- else:
1208
- self.console.print_info("Stopping at user request.")
1209
- break
1210
- except (EOFError, KeyboardInterrupt):
1211
- self.console.print_info("\nStopping at user request.")
1212
- break
1213
- else:
1214
- # Silent mode - just stop
1215
- break
1216
-
1217
1402
  # Display current step
1218
1403
  self.console.print_step_header(steps_taken, steps_limit)
1219
1404
 
@@ -1247,6 +1432,9 @@ You must respond ONLY in valid JSON. No text before { or after }.
1247
1432
  tool_name = next_step["tool"]
1248
1433
  tool_args = next_step["tool_args"]
1249
1434
 
1435
+ # Resolve dynamic parameters from previous step results
1436
+ tool_args = self._resolve_plan_parameters(tool_args, step_results)
1437
+
1250
1438
  # Create a parsed response structure as if it came from the LLM
1251
1439
  parsed = {
1252
1440
  "thought": f"Executing step {self.current_step + 1} of the plan",
@@ -1298,6 +1486,9 @@ You must respond ONLY in valid JSON. No text before { or after }.
1298
1486
  }
1299
1487
  )
1300
1488
 
1489
+ # Store full result for parameter substitution in subsequent plan steps
1490
+ step_results.append(tool_result)
1491
+
1301
1492
  # Share tool output with subsequent LLM calls
1302
1493
  messages.append(
1303
1494
  self._create_tool_message(tool_name, truncated_result)
@@ -1459,9 +1650,14 @@ You must respond ONLY in valid JSON. No text before { or after }.
1459
1650
  )
1460
1651
 
1461
1652
  # Create a specific error recovery prompt
1653
+ last_tool = (
1654
+ tool_call_history[-1][0]
1655
+ if tool_call_history
1656
+ else "unknown tool"
1657
+ )
1462
1658
  prompt = (
1463
1659
  "TOOL EXECUTION FAILED!\n\n"
1464
- f"You were trying to execute: {last_tool_call[0] if last_tool_call else 'unknown tool'}\n"
1660
+ f"You were trying to execute: {last_tool}\n"
1465
1661
  f"Error: {last_error}\n\n"
1466
1662
  f"Original task: {user_input}\n\n"
1467
1663
  f"Current plan step {self.current_step + 1}/{self.total_plan_steps} failed.\n"
@@ -1483,6 +1679,7 @@ You must respond ONLY in valid JSON. No text before { or after }.
1483
1679
  self.current_plan = None
1484
1680
  self.current_step = 0
1485
1681
  self.total_plan_steps = 0
1682
+ step_results.clear() # Clear stale results from failed plan
1486
1683
 
1487
1684
  elif self.execution_state == self.STATE_COMPLETION:
1488
1685
  self.console.print_state_info("COMPLETION: Finalizing response")
@@ -1668,9 +1865,6 @@ You must respond ONLY in valid JSON. No text before { or after }.
1668
1865
  # Add assistant response to messages for chat history
1669
1866
  messages.append({"role": "assistant", "content": response})
1670
1867
 
1671
- # Validate the response has a plan if required
1672
- self._validate_plan_required(parsed, steps_taken)
1673
-
1674
1868
  # If the LLM needs to create a plan first, re-prompt it specifically for that
1675
1869
  if "needs_plan" in parsed and parsed["needs_plan"]:
1676
1870
  # Prepare a special prompt that specifically requests a plan
@@ -1825,8 +2019,15 @@ You must respond ONLY in valid JSON. No text before { or after }.
1825
2019
  for i, step in enumerate(parsed["plan"]):
1826
2020
  if not isinstance(step, dict):
1827
2021
  invalid_steps.append((i, type(step).__name__, step))
1828
- elif "tool" not in step or "tool_args" not in step:
1829
- invalid_steps.append((i, "missing fields", step))
2022
+ elif "tool" not in step:
2023
+ invalid_steps.append((i, "missing tool field", step))
2024
+ elif "tool_args" not in step:
2025
+ # Auto-add empty tool_args for convenience
2026
+ # LLMs sometimes omit this for tools with all optional parameters
2027
+ step["tool_args"] = {}
2028
+ logger.debug(
2029
+ f"Auto-added empty tool_args for step {i+1}: {step['tool']}"
2030
+ )
1830
2031
 
1831
2032
  if invalid_steps:
1832
2033
  logger.error(f"Invalid plan steps found: {invalid_steps}")
@@ -1901,12 +2102,27 @@ You must respond ONLY in valid JSON. No text before { or after }.
1901
2102
  # Start progress indicator for tool execution
1902
2103
  self.console.start_progress(f"Executing {tool_name}")
1903
2104
 
1904
- # Check for repeated tool calls
1905
- if last_tool_call == (tool_name, str(tool_args)):
2105
+ # Check for repeated tool calls (allow up to 3 identical calls)
2106
+ current_call = (tool_name, str(tool_args))
2107
+ tool_call_history.append(current_call)
2108
+
2109
+ # Keep only last 5 calls for loop detection
2110
+ if len(tool_call_history) > 5:
2111
+ tool_call_history.pop(0)
2112
+
2113
+ # Count consecutive identical calls
2114
+ consecutive_count = 0
2115
+ for call in reversed(tool_call_history):
2116
+ if call == current_call:
2117
+ consecutive_count += 1
2118
+ else:
2119
+ break
2120
+
2121
+ # Stop after max_consecutive_repeats identical calls
2122
+ if consecutive_count >= self.max_consecutive_repeats:
1906
2123
  # Stop progress indicator
1907
2124
  self.console.stop_progress()
1908
2125
 
1909
- logger.warning(f"Detected repeated tool call: {tool_name}")
1910
2126
  # Force a final answer if the same tool is called repeatedly
1911
2127
  final_answer = (
1912
2128
  f"Task completed with {tool_name}. No further action needed."
@@ -1942,9 +2158,6 @@ You must respond ONLY in valid JSON. No text before { or after }.
1942
2158
  # Share tool output with subsequent LLM calls
1943
2159
  messages.append(self._create_tool_message(tool_name, truncated_result))
1944
2160
 
1945
- # Update last tool call
1946
- last_tool_call = (tool_name, str(tool_args))
1947
-
1948
2161
  # For single-step plans, we still need to let the LLM process the result
1949
2162
  # This is especially important for RAG queries where the LLM needs to
1950
2163
  # synthesize the retrieved information into a coherent answer
@@ -2015,8 +2228,42 @@ You must respond ONLY in valid JSON. No text before { or after }.
2015
2228
  self.console.print_final_answer(final_answer, streaming=self.streaming)
2016
2229
  break
2017
2230
 
2018
- # Validate plan required
2019
- self._validate_plan_required(parsed, steps_taken)
2231
+ # Check if we're at the limit and ask user if they want to continue
2232
+ if steps_taken == steps_limit and final_answer is None:
2233
+ # Show what was accomplished
2234
+ max_steps_msg = self._generate_max_steps_message(
2235
+ conversation, steps_taken, steps_limit
2236
+ )
2237
+ self.console.print_warning(max_steps_msg)
2238
+
2239
+ # Ask user if they want to continue (skip in silent mode OR if stdin is not available)
2240
+ # IMPORTANT: Never call input() in API/CI contexts to avoid blocking threads
2241
+ import sys
2242
+
2243
+ has_stdin = sys.stdin and sys.stdin.isatty()
2244
+ if has_stdin and not (
2245
+ hasattr(self, "silent_mode") and self.silent_mode
2246
+ ):
2247
+ try:
2248
+ response = (
2249
+ input("\nContinue with 50 more steps? (y/n): ")
2250
+ .strip()
2251
+ .lower()
2252
+ )
2253
+ if response in ["y", "yes"]:
2254
+ steps_limit += 50
2255
+ self.console.print_info(
2256
+ f"✓ Continuing with {steps_limit} total steps...\n"
2257
+ )
2258
+ else:
2259
+ self.console.print_info("Stopping at user request.")
2260
+ break
2261
+ except (EOFError, KeyboardInterrupt):
2262
+ self.console.print_info("\nStopping at user request.")
2263
+ break
2264
+ else:
2265
+ # Silent mode - just stop
2266
+ break
2020
2267
 
2021
2268
  # Print completion message
2022
2269
  self.console.print_completion(steps_taken, steps_limit)
@@ -2132,46 +2379,3 @@ You must respond ONLY in valid JSON. No text before { or after }.
2132
2379
  List of error messages
2133
2380
  """
2134
2381
  return self.error_history
2135
-
2136
- def _validate_plan_required(self, parsed: Dict[str, Any], step: int) -> None:
2137
- """
2138
- Validate that the response includes a plan when required by the agent.
2139
-
2140
- Args:
2141
- parsed: The parsed response from the LLM
2142
- step: The current step number
2143
- """
2144
- # Skip validation if we're not in planning mode or if we're already executing a plan
2145
- if self.execution_state != self.STATE_PLANNING or self.current_plan is not None:
2146
- return
2147
-
2148
- # Allow simple single-tool operations without requiring a plan
2149
- if "tool" in parsed and step == 1:
2150
- tool_name = parsed.get("tool", "")
2151
- # List of tools that can execute directly without a plan
2152
- simple_tools = self.SIMPLE_TOOLS
2153
- if tool_name in simple_tools:
2154
- logger.debug(f"Allowing direct execution of simple tool: {tool_name}")
2155
- return
2156
-
2157
- # Check if plan is missing on the first step
2158
- # BUT: Allow direct answers without plans (for simple conversational queries)
2159
- if "plan" not in parsed and "answer" not in parsed and step == 1:
2160
- warning_msg = f"No plan found in step {step} response. The agent should create a plan for all tasks."
2161
- logger.warning(warning_msg)
2162
- self.console.print_warning(warning_msg)
2163
-
2164
- # For the first step, we'll add a flag to indicate we need to re-prompt for a plan
2165
- parsed["needs_plan"] = True
2166
-
2167
- # If there's a tool in the response, store it but don't execute it yet
2168
- if "tool" in parsed:
2169
- parsed["deferred_tool"] = parsed["tool"]
2170
- parsed["deferred_tool_args"] = parsed.get("tool_args", {})
2171
- # Remove the tool so it won't be executed
2172
- del parsed["tool"]
2173
- if "tool_args" in parsed:
2174
- del parsed["tool_args"]
2175
-
2176
- # Set state to indicate we need planning
2177
- self.execution_state = self.STATE_PLANNING
@@ -13,7 +13,6 @@ Inheritance patterns:
13
13
  - Future: FooAgent(MCPAgent, ApiAgent, Agent) - Multiple inheritance
14
14
  """
15
15
 
16
-
17
16
  from typing import Any, Dict
18
17
 
19
18
  from .agent import Agent