This commit is contained in:
João Moura
2025-06-02 10:18:50 -07:00
parent efebcd9734
commit e4e9bf343a
8 changed files with 979 additions and 836 deletions

View File

@@ -1,176 +1,386 @@
"""Agent state management for long-running tasks.""" """Agent state management for long-running tasks with focus on progress tracking."""
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional, Union, Set
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from datetime import datetime from datetime import datetime
import json
class ToolUsage(BaseModel): class CriterionProgress(BaseModel):
"""Record of a single tool usage.""" """Progress tracking for a single acceptance criterion."""
tool_name: str = Field(description="Name of the tool used") criterion: str = Field(description="The acceptance criterion")
arguments: Dict[str, Any] = Field(description="Arguments passed to the tool (may be truncated)") status: str = Field(default="not_started", description="Status: not_started, in_progress, completed")
result_summary: Optional[str] = Field(default=None, description="Brief summary of the tool's result") progress_notes: str = Field(default="", description="Specific progress made towards this criterion")
timestamp: datetime = Field(default_factory=datetime.now, description="When the tool was used") completion_percentage: int = Field(default=0, description="Estimated completion percentage (0-100)")
step_number: int = Field(description="Which execution step this tool was used in") remaining_work: str = Field(default="", description="What still needs to be done for this criterion")
# Enhanced tracking
processed_items: Set[str] = Field(default_factory=set, description="IDs or identifiers of processed items")
total_items_expected: Optional[int] = Field(default=None, description="Total number of items expected (if known)")
items_to_process: List[str] = Field(default_factory=list, description="Queue of specific items to process next")
last_updated: datetime = Field(default_factory=datetime.now)
class ProgressLog(BaseModel):
"""Single log entry for progress tracking."""
timestamp: datetime = Field(default_factory=datetime.now)
action: str = Field(description="What action was taken")
result: str = Field(description="Result or outcome of the action")
items_processed: List[str] = Field(default_factory=list, description="Items processed in this action")
criterion: Optional[str] = Field(default=None, description="Related acceptance criterion")
class AgentState(BaseModel): class AgentState(BaseModel):
"""Persistent state object for agent task execution. """Enhanced state management with deterministic progress tracking.
This state object helps agents maintain coherence during long-running tasks This state helps agents maintain focus during long executions by tracking
by tracking plans, progress, and intermediate results without relying solely specific progress against each acceptance criterion with detailed logging.
on conversation history.
""" """
# Core fields # Core planning elements
original_plan: List[str] = Field( plan: List[str] = Field(
default_factory=list, default_factory=list,
description="The initial plan from first reasoning pass. Never overwrite unless user requests complete replan" description="The current plan steps"
) )
acceptance_criteria: List[str] = Field( acceptance_criteria: List[str] = Field(
default_factory=list, default_factory=list,
description="Concrete goals to satisfy for task completion" description="Concrete criteria that must be met for task completion"
) )
# Progress tracking
criteria_progress: Dict[str, CriterionProgress] = Field(
default_factory=dict,
description="Detailed progress for each acceptance criterion"
)
# Data storage
scratchpad: Dict[str, Any] = Field( scratchpad: Dict[str, Any] = Field(
default_factory=dict, default_factory=dict,
description="Agent-defined storage for intermediate results and metadata" description="Storage for intermediate results and data"
) )
tool_usage_history: List[ToolUsage] = Field( # Simple tracking
current_focus: str = Field(
default="",
description="What the agent should be focusing on right now"
)
next_steps: List[str] = Field(
default_factory=list, default_factory=list,
description="Detailed history of tool usage including arguments and results" description="Immediate next steps to take"
) )
# Additional tracking fields overall_progress: int = Field(
task_id: Optional[str] = Field(
default=None,
description="ID of the current task being executed"
)
created_at: datetime = Field(
default_factory=datetime.now,
description="When this state was created"
)
last_updated: datetime = Field(
default_factory=datetime.now,
description="When this state was last modified"
)
steps_completed: int = Field(
default=0, default=0,
description="Number of execution steps completed" description="Overall task completion percentage (0-100)"
) )
def set_original_plan(self, plan: List[str]) -> None: # Enhanced tracking
"""Set the original plan (only if not already set).""" progress_logs: List[ProgressLog] = Field(
if not self.original_plan: default_factory=list,
self.original_plan = plan description="Detailed log of all progress made"
self.last_updated = datetime.now() )
work_queue: List[Dict[str, Any]] = Field(
default_factory=list,
description="Queue of specific work items to process"
)
# Metadata tracking
metadata: Dict[str, Any] = Field(
default_factory=dict,
description="Additional metadata for tracking (e.g., total count expectations)"
)
def initialize_criteria_progress(self) -> None:
"""Initialize progress tracking for all acceptance criteria."""
for criterion in self.acceptance_criteria:
if criterion not in self.criteria_progress:
self.criteria_progress[criterion] = CriterionProgress(criterion=criterion)
def update_criterion_progress(
self,
criterion: str,
status: str,
progress_notes: str,
completion_percentage: int,
remaining_work: str,
processed_items: Optional[List[str]] = None,
items_to_process: Optional[List[str]] = None,
total_items_expected: Optional[int] = None
) -> None:
"""Update progress for a specific criterion with enhanced tracking."""
if criterion in self.criteria_progress:
progress = self.criteria_progress[criterion]
progress.status = status
progress.progress_notes = progress_notes
progress.completion_percentage = max(0, min(100, completion_percentage))
progress.remaining_work = remaining_work
progress.last_updated = datetime.now()
# Update processed items
if processed_items:
progress.processed_items.update(processed_items)
# Update items to process queue
if items_to_process is not None:
progress.items_to_process = items_to_process
# Update total expected if provided
if total_items_expected is not None:
progress.total_items_expected = total_items_expected
# Recalculate completion percentage based on actual items if possible
if progress.total_items_expected and progress.total_items_expected > 0:
actual_percentage = int((len(progress.processed_items) / progress.total_items_expected) * 100)
progress.completion_percentage = actual_percentage
# Update overall progress
self._recalculate_overall_progress()
def _recalculate_overall_progress(self) -> None:
"""Recalculate overall progress based on all criteria."""
if not self.criteria_progress:
self.overall_progress = 0
return
total_progress = sum(p.completion_percentage for p in self.criteria_progress.values())
self.overall_progress = int(total_progress / len(self.criteria_progress))
def add_to_scratchpad(self, key: str, value: Any) -> None: def add_to_scratchpad(self, key: str, value: Any) -> None:
"""Add or update a value in the scratchpad.""" """Add or update a value in the scratchpad."""
self.scratchpad[key] = value self.scratchpad[key] = value
self.last_updated = datetime.now()
def record_tool_usage( # Analyze the data for item tracking
self, self._analyze_scratchpad_for_items(key, value)
tool_name: str,
arguments: Dict[str, Any],
result_summary: Optional[str] = None,
max_arg_length: int = 200
) -> None:
"""Record a tool usage with truncated arguments.
Args: def _analyze_scratchpad_for_items(self, key: str, value: Any) -> None:
tool_name: Name of the tool used """Analyze scratchpad data to extract trackable items."""
arguments: Arguments passed to the tool # If it's a list, try to extract IDs
result_summary: Optional brief summary of the result if isinstance(value, list) and value:
max_arg_length: Maximum length for string arguments before truncation item_ids = []
""" for item in value:
# Truncate long string arguments to prevent state bloat if isinstance(item, dict):
truncated_args = {} # Look for common ID fields
for key, value in arguments.items(): for id_field in ['id', 'ID', 'uid', 'uuid', 'message_id', 'email_id']:
if isinstance(value, str) and len(value) > max_arg_length: if id_field in item:
truncated_args[key] = value[:max_arg_length] + "..." item_ids.append(str(item[id_field]))
elif isinstance(value, (list, dict)): break
# For complex types, store a summary
truncated_args[key] = f"<{type(value).__name__} with {len(value)} items>"
else:
truncated_args[key] = value
tool_usage = ToolUsage( if item_ids:
tool_name=tool_name, # Store metadata about this list
arguments=truncated_args, self.metadata[f"{key}_ids"] = item_ids
result_summary=result_summary, self.metadata[f"{key}_count"] = len(value)
step_number=self.steps_completed
def log_progress(self, action: str, result: str, items_processed: Optional[List[str]] = None, criterion: Optional[str] = None) -> None:
"""Add a progress log entry."""
log_entry = ProgressLog(
action=action,
result=result,
items_processed=items_processed or [],
criterion=criterion
) )
self.progress_logs.append(log_entry)
self.tool_usage_history.append(tool_usage) def add_to_work_queue(self, work_item: Dict[str, Any]) -> None:
self.last_updated = datetime.now() """Add an item to the work queue."""
self.work_queue.append(work_item)
def increment_steps(self) -> None: def get_next_work_item(self) -> Optional[Dict[str, Any]]:
"""Increment the step counter.""" """Get and remove the next item from the work queue."""
self.steps_completed += 1 if self.work_queue:
self.last_updated = datetime.now() return self.work_queue.pop(0)
return None
def reset(self, task_id: Optional[str] = None) -> None: def set_focus_and_next_steps(self, focus: str, next_steps: List[str]) -> None:
"""Reset state for a new task.""" """Update current focus and next steps."""
self.original_plan = [] self.current_focus = focus
self.acceptance_criteria = [] self.next_steps = next_steps
self.scratchpad = {}
self.tool_usage_history = []
self.task_id = task_id
self.created_at = datetime.now()
self.last_updated = datetime.now()
self.steps_completed = 0
def to_context_string(self) -> str: def get_progress_context(self) -> str:
"""Generate a concise string representation for LLM context.""" """Generate a focused progress update for the agent."""
context = f"Current State (Step {self.steps_completed}):\n" context = f"📊 PROGRESS UPDATE (Overall: {self.overall_progress}%)\n"
context += f"- Task ID: {self.task_id}\n" context += "="*50 + "\n\n"
if self.acceptance_criteria: # Current focus
context += "- Acceptance Criteria:\n" if self.current_focus:
for criterion in self.acceptance_criteria: context += f"🎯 CURRENT FOCUS: {self.current_focus}\n\n"
context += f"{criterion}\n"
if self.original_plan: # Progress on each criterion with detailed tracking
context += "- Plan:\n" if self.criteria_progress:
for i, step in enumerate(self.original_plan, 1): context += "📋 ACCEPTANCE CRITERIA PROGRESS:\n"
context += f" {i}. {step}\n" for criterion, progress in self.criteria_progress.items():
status_emoji = "" if progress.status == "completed" else "🔄" if progress.status == "in_progress" else "⏸️"
context += f"\n{status_emoji} {criterion}\n"
if self.tool_usage_history: # Show detailed progress
context += "- Recent Tool Usage:\n" if progress.total_items_expected:
# Show last 5 tool uses context += f" Progress: {len(progress.processed_items)}/{progress.total_items_expected} items ({progress.completion_percentage}%)\n"
recent_tools = self.tool_usage_history[-5:] else:
for usage in recent_tools: context += f" Progress: {progress.completion_percentage}%"
context += f" • Step {usage.step_number}: {usage.tool_name}" if progress.processed_items:
if usage.arguments: context += f" - {len(progress.processed_items)} items processed"
args_preview = ", ".join(f"{k}={v}" for k, v in list(usage.arguments.items())[:2]) context += "\n"
context += f"({args_preview})"
if progress.progress_notes:
context += f" Notes: {progress.progress_notes}\n"
# Show next items to process
if progress.items_to_process and progress.status != "completed":
next_items = progress.items_to_process[:3] # Show next 3
context += f" Next items: {', '.join(next_items)}"
if len(progress.items_to_process) > 3:
context += f" (and {len(progress.items_to_process) - 3} more)"
context += "\n"
if progress.remaining_work and progress.status != "completed":
context += f" Still needed: {progress.remaining_work}\n"
# Work queue status
if self.work_queue:
context += f"\n📝 WORK QUEUE: {len(self.work_queue)} items pending\n"
next_work = self.work_queue[0]
context += f" Next: {next_work.get('description', 'Process next item')}\n"
# Next steps
if self.next_steps:
context += f"\n📍 IMMEDIATE NEXT STEPS:\n"
for i, step in enumerate(self.next_steps, 1):
context += f"{i}. {step}\n"
# Available data
if self.scratchpad:
context += f"\n💾 AVAILABLE DATA IN SCRATCHPAD:\n"
for key, value in self.scratchpad.items():
if isinstance(value, list):
context += f"'{key}' - {len(value)} items"
if f"{key}_ids" in self.metadata:
context += f" (IDs tracked)"
context += "\n"
elif isinstance(value, dict):
context += f"'{key}' - dictionary data\n"
else:
context += f"'{key}'\n"
# Recent progress logs
if self.progress_logs:
context += f"\n📜 RECENT ACTIVITY:\n"
for log in self.progress_logs[-3:]: # Show last 3 logs
context += f"{log.timestamp.strftime('%H:%M:%S')} - {log.action}"
if log.items_processed:
context += f" ({len(log.items_processed)} items)"
context += "\n" context += "\n"
if self.scratchpad: context += "\n" + "="*50
context += "- Scratchpad:\n"
for key, value in self.scratchpad.items():
context += f"{key}: {value}\n"
return context return context
def get_tools_summary(self) -> Dict[str, Any]: def analyze_scratchpad_for_criterion_progress(self, criterion: str) -> Dict[str, Any]:
"""Get a summary of tool usage statistics.""" """Analyze scratchpad data to determine specific progress on a criterion."""
if not self.tool_usage_history: analysis = {
return {"total_tool_uses": 0, "unique_tools": 0, "tools_by_frequency": {}} "relevant_data": [],
"item_count": 0,
"processed_ids": set(),
"data_completeness": 0,
"specific_gaps": []
}
tool_counts = {} criterion_lower = criterion.lower()
for usage in self.tool_usage_history:
tool_counts[usage.tool_name] = tool_counts.get(usage.tool_name, 0) + 1
return { # Look for data that relates to this criterion
"total_tool_uses": len(self.tool_usage_history), for key, value in self.scratchpad.items():
"unique_tools": len(set(usage.tool_name for usage in self.tool_usage_history)), key_lower = key.lower()
"tools_by_frequency": dict(sorted(tool_counts.items(), key=lambda x: x[1], reverse=True))
} # Check if this data is relevant to the criterion
is_relevant = False
for keyword in criterion_lower.split():
if len(keyword) > 3 and keyword in key_lower: # Skip short words
is_relevant = True
break
if is_relevant:
analysis["relevant_data"].append(key)
# Count items and extract IDs
if isinstance(value, list):
analysis["item_count"] += len(value)
# Try to extract IDs from metadata
if f"{key}_ids" in self.metadata:
analysis["processed_ids"].update(self.metadata[f"{key}_ids"])
elif isinstance(value, dict):
analysis["item_count"] += 1
# Calculate completeness based on what we know
if analysis["item_count"] > 0:
# Check if criterion mentions specific numbers
import re
number_match = re.search(r'\b(\d+)\b', criterion)
if number_match:
expected_count = int(number_match.group(1))
analysis["data_completeness"] = min(100, int((analysis["item_count"] / expected_count) * 100))
if analysis["item_count"] < expected_count:
analysis["specific_gaps"].append(f"Need {expected_count - analysis['item_count']} more items")
else:
# For criteria without specific numbers, use heuristics
if "all" in criterion_lower or "every" in criterion_lower:
# For "all" criteria, we need to be more careful
analysis["data_completeness"] = 50 if analysis["item_count"] > 0 else 0
analysis["specific_gaps"].append("Verify all items are included")
else:
analysis["data_completeness"] = min(100, analysis["item_count"] * 20) # Rough estimate
return analysis
def generate_specific_next_steps(self, criterion: str) -> List[str]:
"""Generate specific, actionable next steps for a criterion."""
analysis = self.analyze_scratchpad_for_criterion_progress(criterion)
progress = self.criteria_progress.get(criterion)
next_steps = []
if not progress:
return ["Initialize progress tracking for this criterion"]
# If we have a queue of items to process
if progress.items_to_process:
next_item = progress.items_to_process[0]
next_steps.append(f"Query/process item: {next_item}")
if len(progress.items_to_process) > 1:
next_steps.append(f"Then process {len(progress.items_to_process) - 1} remaining items")
# If we have processed some items but not all
elif progress.processed_items and progress.total_items_expected:
remaining = progress.total_items_expected - len(progress.processed_items)
if remaining > 0:
next_steps.append(f"Process {remaining} more items to reach target of {progress.total_items_expected}")
# If we have data but haven't accessed it
elif analysis["relevant_data"] and not progress.processed_items:
for data_key in analysis["relevant_data"][:2]: # First 2 relevant keys
next_steps.append(f"Access and process data from '{data_key}'")
# Generic steps based on criterion keywords
else:
criterion_lower = criterion.lower()
if "email" in criterion_lower:
next_steps.append("Use email search/fetch tool to gather emails")
elif "analyze" in criterion_lower or "summary" in criterion_lower:
next_steps.append("Access stored data and create analysis/summary")
else:
next_steps.append(f"Use appropriate tools to gather data for: {criterion}")
return next_steps
def reset(self) -> None:
"""Reset state for a new task."""
self.plan = []
self.acceptance_criteria = []
self.criteria_progress = {}
self.scratchpad = {}
self.current_focus = ""
self.next_steps = []
self.overall_progress = 0
self.progress_logs = []
self.work_queue = []
self.metadata = {}

File diff suppressed because it is too large Load Diff

View File

@@ -74,7 +74,7 @@ class FilteredStream(io.TextIOBase):
"give feedback / get help" in lower_s "give feedback / get help" in lower_s
or "litellm.info:" in lower_s or "litellm.info:" in lower_s
or "litellm" in lower_s or "litellm" in lower_s
or "Consider using a smaller input or implementing a text splitting strategy" in lower_s or "consider using a smaller input or implementing a text splitting strategy" in lower_s
): ):
return 0 return 0

View File

@@ -1,6 +1,6 @@
"""Tool for accessing data stored in the agent's scratchpad during reasoning.""" """Tool for accessing data stored in the agent's scratchpad during reasoning."""
from typing import Any, Dict, Optional, Type, Union from typing import Any, Dict, Optional, Type, Union, Callable
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from crewai.tools import BaseTool from crewai.tools import BaseTool
@@ -29,6 +29,10 @@ class ScratchpadTool(BaseTool):
args_schema: Type[BaseModel] = ScratchpadToolSchema args_schema: Type[BaseModel] = ScratchpadToolSchema
scratchpad_data: Dict[str, Any] = Field(default_factory=dict) scratchpad_data: Dict[str, Any] = Field(default_factory=dict)
# Allow repeated usage of this tool - scratchpad access should not be limited
cache_function: Callable = lambda _args, _result: False # Don't cache scratchpad access
allow_repeated_usage: bool = True # Allow accessing the same key multiple times
def __init__(self, scratchpad_data: Optional[Dict[str, Any]] = None, **kwargs): def __init__(self, scratchpad_data: Optional[Dict[str, Any]] = None, **kwargs):
"""Initialize the scratchpad tool with optional initial data. """Initialize the scratchpad tool with optional initial data.
@@ -53,25 +57,46 @@ class ScratchpadTool(BaseTool):
Returns: Returns:
The value associated with the key, or an error message if not found The value associated with the key, or an error message if not found
""" """
print(f"[DEBUG] ScratchpadTool._run called with key: '{key}'")
print(f"[DEBUG] Current scratchpad keys: {list(self.scratchpad_data.keys())}")
print(f"[DEBUG] Scratchpad data size: {len(self.scratchpad_data)}")
if not self.scratchpad_data: if not self.scratchpad_data:
return ( return (
"❌ SCRATCHPAD IS EMPTY\n\n" "❌ SCRATCHPAD IS EMPTY\n\n"
"The scratchpad does not contain any data yet.\n" "The scratchpad does not contain any data yet.\n"
"Data will be automatically stored here as you use other tools.\n" "Data will be automatically stored here as you use other tools.\n"
"Try executing other tools first to gather information." "Try executing other tools first to gather information.\n\n"
"💡 TIP: Tools like search, read, or fetch operations will automatically store their results in the scratchpad."
) )
if key not in self.scratchpad_data: if key not in self.scratchpad_data:
available_keys = list(self.scratchpad_data.keys()) available_keys = list(self.scratchpad_data.keys())
keys_formatted = "\n".join(f" - '{k}'" for k in available_keys) keys_formatted = "\n".join(f" - '{k}'" for k in available_keys)
# Create more helpful examples based on actual keys
example_key = available_keys[0] if available_keys else 'example_key'
# Check if the user tried a similar key (case-insensitive or partial match)
similar_keys = [k for k in available_keys if key.lower() in k.lower() or k.lower() in key.lower()]
similarity_hint = ""
if similar_keys:
similarity_hint = f"\n\n🔍 Did you mean one of these?\n" + "\n".join(f" - '{k}'" for k in similar_keys)
return ( return (
f"❌ KEY NOT FOUND: '{key}'\n\n" f"❌ KEY NOT FOUND: '{key}'\n"
f"{'='*50}\n\n"
f"The key '{key}' does not exist in the scratchpad.\n\n" f"The key '{key}' does not exist in the scratchpad.\n\n"
f"Available keys:\n{keys_formatted}\n\n" f"📦 AVAILABLE KEYS IN SCRATCHPAD:\n{keys_formatted}\n"
f"To retrieve data, use the EXACT key name from the list above.\n" f"{similarity_hint}\n\n"
f"Example Action Input: {{\"key\": \"{available_keys[0] if available_keys else 'example_key'}\"}}\n\n" f"✅ CORRECT USAGE EXAMPLE:\n"
f"Remember: Keys are case-sensitive and must match exactly!" f"Action: Access Scratchpad Memory\n"
f"Action Input: {{\"key\": \"{example_key}\"}}\n\n"
f"⚠️ IMPORTANT:\n"
f"- Keys are case-sensitive and must match EXACTLY\n"
f"- Use the exact key name from the list above\n"
f"- Do NOT modify or guess key names\n\n"
f"{'='*50}"
) )
value = self.scratchpad_data[key] value = self.scratchpad_data[key]
@@ -79,12 +104,16 @@ class ScratchpadTool(BaseTool):
# Format the output nicely based on the type # Format the output nicely based on the type
if isinstance(value, dict): if isinstance(value, dict):
import json import json
return json.dumps(value, indent=2) formatted_output = f"✅ Successfully retrieved data for key '{key}':\n\n"
formatted_output += json.dumps(value, indent=2)
return formatted_output
elif isinstance(value, list): elif isinstance(value, list):
import json import json
return json.dumps(value, indent=2) formatted_output = f"✅ Successfully retrieved data for key '{key}':\n\n"
formatted_output += json.dumps(value, indent=2)
return formatted_output
else: else:
return str(value) return f"✅ Successfully retrieved data for key '{key}':\n\n{str(value)}"
def update_scratchpad(self, new_data: Dict[str, Any]) -> None: def update_scratchpad(self, new_data: Dict[str, Any]) -> None:
"""Update the scratchpad data and refresh the tool description. """Update the scratchpad data and refresh the tool description.

View File

@@ -39,6 +39,8 @@ class BaseTool(BaseModel, ABC):
"""Maximum number of times this tool can be used. None means unlimited usage.""" """Maximum number of times this tool can be used. None means unlimited usage."""
current_usage_count: int = 0 current_usage_count: int = 0
"""Current number of times this tool has been used.""" """Current number of times this tool has been used."""
allow_repeated_usage: bool = False
"""Flag to allow this tool to be used repeatedly with the same arguments."""
@field_validator("args_schema", mode="before") @field_validator("args_schema", mode="before")
@classmethod @classmethod
@@ -57,7 +59,7 @@ class BaseTool(BaseModel, ABC):
}, },
}, },
) )
@field_validator("max_usage_count", mode="before") @field_validator("max_usage_count", mode="before")
@classmethod @classmethod
def validate_max_usage_count(cls, v: int | None) -> int | None: def validate_max_usage_count(cls, v: int | None) -> int | None:
@@ -81,11 +83,11 @@ class BaseTool(BaseModel, ABC):
# If _run is async, we safely run it # If _run is async, we safely run it
if asyncio.iscoroutine(result): if asyncio.iscoroutine(result):
result = asyncio.run(result) result = asyncio.run(result)
self.current_usage_count += 1 self.current_usage_count += 1
return result return result
def reset_usage_count(self) -> None: def reset_usage_count(self) -> None:
"""Reset the current usage count to zero.""" """Reset the current usage count to zero."""
self.current_usage_count = 0 self.current_usage_count = 0
@@ -109,6 +111,8 @@ class BaseTool(BaseModel, ABC):
result_as_answer=self.result_as_answer, result_as_answer=self.result_as_answer,
max_usage_count=self.max_usage_count, max_usage_count=self.max_usage_count,
current_usage_count=self.current_usage_count, current_usage_count=self.current_usage_count,
allow_repeated_usage=self.allow_repeated_usage,
cache_function=self.cache_function,
) )
@classmethod @classmethod
@@ -272,7 +276,7 @@ def to_langchain(
def tool(*args, result_as_answer: bool = False, max_usage_count: int | None = None) -> Callable: def tool(*args, result_as_answer: bool = False, max_usage_count: int | None = None) -> Callable:
""" """
Decorator to create a tool from a function. Decorator to create a tool from a function.
Args: Args:
*args: Positional arguments, either the function to decorate or the tool name. *args: Positional arguments, either the function to decorate or the tool name.
result_as_answer: Flag to indicate if the tool result should be used as the final agent answer. result_as_answer: Flag to indicate if the tool result should be used as the final agent answer.

View File

@@ -25,6 +25,8 @@ class CrewStructuredTool:
result_as_answer: bool = False, result_as_answer: bool = False,
max_usage_count: int | None = None, max_usage_count: int | None = None,
current_usage_count: int = 0, current_usage_count: int = 0,
allow_repeated_usage: bool = False,
cache_function: Optional[Callable] = None,
) -> None: ) -> None:
"""Initialize the structured tool. """Initialize the structured tool.
@@ -36,6 +38,8 @@ class CrewStructuredTool:
result_as_answer: Whether to return the output directly result_as_answer: Whether to return the output directly
max_usage_count: Maximum number of times this tool can be used. None means unlimited usage. max_usage_count: Maximum number of times this tool can be used. None means unlimited usage.
current_usage_count: Current number of times this tool has been used. current_usage_count: Current number of times this tool has been used.
allow_repeated_usage: Whether to allow this tool to be used repeatedly with the same arguments.
cache_function: Function that will be used to determine if the tool should be cached.
""" """
self.name = name self.name = name
self.description = description self.description = description
@@ -45,6 +49,8 @@ class CrewStructuredTool:
self.result_as_answer = result_as_answer self.result_as_answer = result_as_answer
self.max_usage_count = max_usage_count self.max_usage_count = max_usage_count
self.current_usage_count = current_usage_count self.current_usage_count = current_usage_count
self.allow_repeated_usage = allow_repeated_usage
self.cache_function = cache_function if cache_function is not None else lambda _args=None, _result=None: True
# Validate the function signature matches the schema # Validate the function signature matches the schema
self._validate_function_signature() self._validate_function_signature()

View File

@@ -149,7 +149,13 @@ class ToolUsage:
tool: CrewStructuredTool, tool: CrewStructuredTool,
calling: Union[ToolCalling, InstructorToolCalling], calling: Union[ToolCalling, InstructorToolCalling],
) -> str: ) -> str:
if self._check_tool_repeated_usage(calling=calling): # type: ignore # _check_tool_repeated_usage of "ToolUsage" does not return a value (it only ever returns None) # Check if tool allows repeated usage before blocking
allows_repeated = False
if hasattr(tool, 'allow_repeated_usage'):
allows_repeated = tool.allow_repeated_usage
elif hasattr(tool, '_tool') and hasattr(tool._tool, 'allow_repeated_usage'):
allows_repeated = tool._tool.allow_repeated_usage
if not allows_repeated and self._check_tool_repeated_usage(calling=calling): # type: ignore # _check_tool_repeated_usage of "ToolUsage" does not return a value (it only ever returns None)
try: try:
result = self._i18n.errors("task_repeated_usage").format( result = self._i18n.errors("task_repeated_usage").format(
tool_names=self.tools_names tool_names=self.tools_names
@@ -369,6 +375,11 @@ class ToolUsage:
def _format_result(self, result: Any) -> str: def _format_result(self, result: Any) -> str:
if self.task: if self.task:
self.task.used_tools += 1 self.task.used_tools += 1
# Handle None results explicitly
if result is None:
result = "No result returned from tool"
if self._should_remember_format(): if self._should_remember_format():
result = self._remember_format(result=result) result = self._remember_format(result=result)
return str(result) return str(result)
@@ -391,9 +402,19 @@ class ToolUsage:
if not self.tools_handler: if not self.tools_handler:
return False return False
if last_tool_usage := self.tools_handler.last_used_tool: if last_tool_usage := self.tools_handler.last_used_tool:
return (calling.tool_name == last_tool_usage.tool_name) and ( # Add debug logging
print(f"[DEBUG] _check_tool_repeated_usage:")
print(f" Current tool: {calling.tool_name}")
print(f" Current args: {calling.arguments}")
print(f" Last tool: {last_tool_usage.tool_name}")
print(f" Last args: {last_tool_usage.arguments}")
is_repeated = (calling.tool_name == last_tool_usage.tool_name) and (
calling.arguments == last_tool_usage.arguments calling.arguments == last_tool_usage.arguments
) )
print(f" Is repeated: {is_repeated}")
return is_repeated
return False return False
def _check_usage_limit(self, tool: Any, tool_name: str) -> str | None: def _check_usage_limit(self, tool: Any, tool_name: str) -> str | None:

View File

@@ -23,6 +23,8 @@ class TestScratchpadTool:
assert "❌ SCRATCHPAD IS EMPTY" in result assert "❌ SCRATCHPAD IS EMPTY" in result
assert "does not contain any data yet" in result assert "does not contain any data yet" in result
assert "Try executing other tools first" in result assert "Try executing other tools first" in result
assert "💡 TIP:" in result
assert "search, read, or fetch operations" in result
def test_key_not_found_error_message(self): def test_key_not_found_error_message(self):
"""Test error message when key is not found.""" """Test error message when key is not found."""
@@ -34,11 +36,14 @@ class TestScratchpadTool:
result = tool._run(key="wrong_key") result = tool._run(key="wrong_key")
assert "❌ KEY NOT FOUND: 'wrong_key'" in result assert "❌ KEY NOT FOUND: 'wrong_key'" in result
assert "Available keys:" in result assert "📦 AVAILABLE KEYS IN SCRATCHPAD:" in result
assert "- 'existing_key'" in result assert "- 'existing_key'" in result
assert "- 'another_key'" in result assert "- 'another_key'" in result
assert 'Example Action Input: {"key": "existing_key"}' in result assert '✅ CORRECT USAGE EXAMPLE:' in result
assert "Keys are case-sensitive" in result assert 'Action: Access Scratchpad Memory' in result
assert 'Action Input: {"key": "existing_key"}' in result
assert "⚠️ IMPORTANT:" in result
assert "Keys are case-sensitive and must match EXACTLY" in result
def test_successful_retrieval_string(self): def test_successful_retrieval_string(self):
"""Test successful retrieval of string data.""" """Test successful retrieval of string data."""
@@ -47,7 +52,8 @@ class TestScratchpadTool:
}) })
result = tool._run(key="message") result = tool._run(key="message")
assert result == "Hello, World!" assert "✅ Successfully retrieved data for key 'message':" in result
assert "Hello, World!" in result
def test_successful_retrieval_dict(self): def test_successful_retrieval_dict(self):
"""Test successful retrieval of dictionary data.""" """Test successful retrieval of dictionary data."""
@@ -57,6 +63,7 @@ class TestScratchpadTool:
}) })
result = tool._run(key="user_data") result = tool._run(key="user_data")
assert "✅ Successfully retrieved data for key 'user_data':" in result
assert '"name": "John"' in result assert '"name": "John"' in result
assert '"age": 30' in result assert '"age": 30' in result
@@ -68,6 +75,7 @@ class TestScratchpadTool:
}) })
result = tool._run(key="items") result = tool._run(key="items")
assert "✅ Successfully retrieved data for key 'items':" in result
assert '"item1"' in result assert '"item1"' in result
assert '"item2"' in result assert '"item2"' in result
assert '"item3"' in result assert '"item3"' in result
@@ -134,4 +142,35 @@ class TestScratchpadTool:
assert "📌 'nested_dict': list of 3 items" in desc assert "📌 'nested_dict': list of 3 items" in desc
assert "📌 'empty_list': list of 0 items" in desc assert "📌 'empty_list': list of 0 items" in desc
assert "📌 'boolean_value': bool" in desc assert "📌 'boolean_value': bool" in desc
assert "📌 'number': int" in desc assert "📌 'number': int" in desc
def test_similar_key_suggestion(self):
"""Test that similar keys are suggested when a wrong key is used."""
tool = ScratchpadTool(scratchpad_data={
"email_search_results": ["email1", "email2"],
"email_details": {"id": "123"},
"user_preferences": {"theme": "dark"}
})
# Test partial match
result = tool._run(key="email")
assert "🔍 Did you mean one of these?" in result
# Check that similar keys are in the suggestions
# Extract just the "Did you mean" section
did_you_mean_section = result.split("🔍 Did you mean one of these?")[1].split("✅ CORRECT USAGE EXAMPLE:")[0]
assert "- 'email_search_results'" in did_you_mean_section
assert "- 'email_details'" in did_you_mean_section
assert "- 'user_preferences'" not in did_you_mean_section
# But user_preferences should still be in the full list
assert "- 'user_preferences'" in result
# Test case-insensitive match
result = tool._run(key="EMAIL_DETAILS")
assert "🔍 Did you mean one of these?" in result
assert "- 'email_details'" in result
# Test no similar keys
result = tool._run(key="completely_different")
assert "🔍 Did you mean one of these?" not in result