diff --git a/examples/neo_skill_agent_demo.py b/examples/neo_skill_agent_demo.py new file mode 100755 index 0000000..e9542c6 --- /dev/null +++ b/examples/neo_skill_agent_demo.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +""" +Neo N3 Research Skill Agent Demo + +This example demonstrates how to use the SpoonReactSkill agent with the +Neo Query skill for comprehensive blockchain data analysis. + +Features: +- Skill-based agent for Neo N3 +- Script-based Neo RPC integration (via neo-query skill) +- Advanced analysis of blocks, addresses, and governance +""" + +import os +import sys +import asyncio +import logging +from pathlib import Path +from dotenv import load_dotenv + +from spoon_ai.agents import SpoonReactSkill +from spoon_ai.chat import ChatBot + +# Load environment variables +load_dotenv(override=True) + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +# Path to example skills +EXAMPLES_SKILLS_PATH = str(Path(__file__).parent / "skills") + + +class NeoResearchSkillAgent(SpoonReactSkill): + """ + A Neo-focused research agent that uses the neo-query skill + for deep blockchain data analysis. + """ + + def __init__(self, **kwargs): + # Set default values before super().__init__ + kwargs.setdefault('name', 'neo_research_skill_agent') + kwargs.setdefault('description', 'AI agent specialized in Neo N3 blockchain research') + kwargs.setdefault('system_prompt', self._get_system_prompt()) + kwargs.setdefault('max_steps', 10) + kwargs.setdefault('_default_timeout', 120.0) + + # Configure skill paths to include examples/skills + kwargs.setdefault('skill_paths', [EXAMPLES_SKILLS_PATH]) + + # Enable scripts for data query + kwargs.setdefault('scripts_enabled', True) + + super().__init__(**kwargs) + + @staticmethod + def _get_system_prompt() -> str: + return """You are a top-tier Neo N3 Blockchain Analyst. + +Your mission is to provide deep, accurate, and professional analysis of the Neo ecosystem. +You have access to the `neo-query` skill, which provides direct RPC access to Neo data. + +When analyzing Neo topics: +1. Use `run_script_neo-query_neo_rpc_query` to fetch real-time data. +2. For addresses: Always check balances (NEO/GAS) and recent transfer history to understand the user's profile. +3. For governance: Use committee and candidate tools to explain the voting landscape. +4. For contracts: Look for verification status and analyze recent invocation logs if needed. +5. Context: Default to Neo Testnet unless Mainnet is specified. + +Structure your responses professionally, using tables for data comparison where appropriate. +Always explain technical terms (like NEP-17, GAS, UInt160) in a user-friendly way. +""" + + async def initialize(self, __context=None): + """Initialize the agent and activate Neo skills.""" + await super().initialize(__context) + + skills = self.list_skills() + logger.info(f"Available skills: {skills}") + + async def analyze(self, query: str) -> str: + logger.info(f"Starting Neo analysis: {query}") + response = await self.run(query) + return response + + +async def demo_neo_analysis(): + """Run a demo of Neo blockchain analysis.""" + print("\n" + "=" * 60) + print("Neo N3 Research Skill Agent Demo") + print("(Powered by neo-query skill scripts)") + print("=" * 60) + + # Create agent + agent = NeoResearchSkillAgent( + llm=ChatBot(), + auto_trigger_skills=True + ) + + # Initialize + await agent.initialize() + + # Test cases + test_queries = [ + "What is the current status of the Neo Testnet? Show me the latest block height and committee members.", + "Check the balance and recent NEP-17 activity for Neo address NUTtedVrz5RgKAdCvtKiq3sRkb9pizcewe.", + "Search for a contract named 'Flamingo' and tell me its hash and verification status." + ] + + for i, query in enumerate(test_queries, 1): + print(f"\n[Test {i}] Query: {query}") + print("-" * 60) + response = await agent.analyze(query) + print(f"\nAnalysis Result:\n{response}") + print("-" * 60) + await asyncio.sleep(2) + + +async def demo_interactive(): + """Interactive mode for Neo research.""" + print("\n" + "=" * 60) + print("Neo N3 Research Agent - Interactive Mode") + print("Type your Neo-related questions (e.g., 'Check balance of N...', 'Who is in the council?')") + print("Type 'exit' to quit.") + print("=" * 60) + + agent = NeoResearchSkillAgent(llm=ChatBot()) + await agent.initialize() + + while True: + try: + user_input = input("\nYou: ").strip() + if not user_input or user_input.lower() in ['exit', 'quit', 'q']: + break + + response = await agent.analyze(user_input) + print(f"\nAgent: {response}") + except KeyboardInterrupt: + break + except Exception as e: + print(f"Error: {e}") + +async def main(): + # Intelligent LLM Provider selection + from spoon_ai.llm.manager import get_llm_manager + if not (os.getenv("OPENAI_API_KEY") or os.getenv("GEMINI_API_KEY") or os.getenv("OPENROUTER_API_KEY")): + print("Error: No LLM API key found.") + sys.exit(1) + + print("\nSelect demo mode:") + print("1. Automatic Demo (3 scenarios)") + print("2. Interactive mode") + + choice = input("\nEnter choice (1-2, default=1): ").strip() or "1" + + if choice == "1": + await demo_neo_analysis() + else: + await demo_interactive() + + +if __name__ == "__main__": + asyncio.run(main()) + diff --git a/examples/skills/neo-query/SKILL.md b/examples/skills/neo-query/SKILL.md new file mode 100644 index 0000000..dd82af0 --- /dev/null +++ b/examples/skills/neo-query/SKILL.md @@ -0,0 +1,96 @@ +--- +name: neo-query +description: Comprehensive Neo N3 blockchain data query and analysis skill +version: 1.1.0 +author: XSpoonAi Team +tags: + - neo + - n3 + - blockchain + - smart-contract + - query + - analytics +triggers: + - type: keyword + keywords: + - neo + - n3 + - gas + - antshares + - nep17 + - nep11 + - neo address + - neo block + - neo transaction + - neo contract + - neo committee + - neo voting + - neo candidates + priority: 95 + - type: pattern + patterns: + - "(?i)(query|check|analyze|investigate) .*(neo|gas|n3|nep17|nep11)" + - "(?i)what is the (balance|status|history) of neo address .*" + - "(?i)find (contracts|assets|tokens) on neo .*" + priority: 90 +parameters: + - name: address + type: string + required: false + description: The Neo N3 address to query (starts with N) + - name: network + type: string + required: false + default: testnet + description: Neo network (mainnet or testnet) +scripts: + enabled: true + working_directory: ./scripts + definitions: + - name: neo_rpc_query + description: | + Execute various Neo N3 blockchain queries. + Pass a JSON command to stdin. + Supported actions: get_balance, get_address_info, get_block, get_transaction, + get_contract, get_contract_list (use 'contract_name'), get_asset_info (use 'asset_name'), + get_nep17_transfers, get_nep11_transfers, get_candidates, get_committee, get_logs. + type: python + file: neo_rpc_query.py + timeout: 100 +--- + +# Neo Query Skill + +You are now in **Neo Blockchain Specialist Mode**. You have access to the full suite of Neo N3 data analysis tools. + +## Capabilities +- **Address Analysis**: Balances, transfer history, and transaction counts. +- **Asset & Token Tracking**: NEP-17 (fungible) and NEP-11 (NFT) balances and transfers. +- **Blockchain Exploration**: Block details, rewards, and network status. +- **Contract & Ecosystem**: Smart contract metadata, verified status, and application logs. +- **Governance**: Voting candidates, committee members, and total votes. + +## Guidelines +1. **Address Format**: Neo N3 addresses typically start with 'N'. +2. **Network**: Default to `testnet` for safety unless `mainnet` is explicitly requested. +3. **Pagination**: For history or lists, you can suggest a `limit` and `skip`. +4. **Analysis**: Don't just show raw data; explain what the balances or transaction patterns mean for the user. + +## Available Scripts + +### neo_rpc_query +Execute queries by passing a JSON command via stdin. + +**Command Examples:** +- **Balance**: `{"action": "get_address_info", "address": "N..."}` +- **Block**: `{"action": "get_block", "height": 12345}` +- **Transactions**: `{"action": "get_transaction", "hash": "0x..."}` +- **NEP-17 History**: `{"action": "get_nep17_transfers", "address": "N...", "limit": 10}` +- **Contracts**: `{"action": "get_contract_list", "contract_name": "Flamingo"}` +- **Governance**: `{"action": "get_committee"}` + +## Example Queries +1. "Analyze the portfolio and recent activity of address N..." +2. "Who are the current Neo council/committee members?" +3. "Check the details and source code verification for contract 0x..." +4. "Search for NEP-17 tokens named 'GAS' or 'USDT' on Neo." diff --git a/examples/skills/neo-query/scripts/neo_rpc_query.py b/examples/skills/neo-query/scripts/neo_rpc_query.py new file mode 100755 index 0000000..2c1e48b --- /dev/null +++ b/examples/skills/neo-query/scripts/neo_rpc_query.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +""" +Comprehensive Neo RPC Query Script for neo-query skill. +Maps AI requests to the full set of tools in spoon_toolkits.crypto.neo. +""" + +import os +import sys +import json +import asyncio +from typing import Any, Dict + +# Setup toolkit paths +CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) +ROOT_DIR = os.path.abspath(os.path.join(CURRENT_DIR, "../../../../../")) +TOOLKIT_PATH = os.path.join(ROOT_DIR, "spoon-toolkit") + +if TOOLKIT_PATH not in sys.path: + sys.path.append(TOOLKIT_PATH) + +try: + from spoon_toolkits.crypto.neo.address_tools import GetAddressInfoTool, GetTransferByAddressTool + from spoon_toolkits.crypto.neo.block_tools import GetBlockByHeightTool, GetBlockCountTool, GetBestBlockHashTool, GetRecentBlocksInfoTool + from spoon_toolkits.crypto.neo.transaction_tools import GetRawTransactionByTransactionHashTool, GetTransactionCountByAddressTool + from spoon_toolkits.crypto.neo.contract_tools import GetContractByHashTool, GetContractListByNameTool + from spoon_toolkits.crypto.neo.nep_tools import GetNep17TransferByAddressTool, GetNep11TransferByAddressTool, GetNep17TransferCountByAddressTool + from spoon_toolkits.crypto.neo.governance_tools import GetCommitteeInfoTool + from spoon_toolkits.crypto.neo.voting_tools import GetCandidateCountTool, GetTotalVotesTool + from spoon_toolkits.crypto.neo.log_state_tools import GetApplicationLogTool + from spoon_toolkits.crypto.neo.asset_tools import GetAssetInfoByNameTool +except ImportError as e: + print(json.dumps({ + "status": "error", + "message": f"Failed to import neo toolkit: {str(e)}" + })) + sys.exit(1) + + +async def run_query(command: Dict[str, Any]) -> Dict[str, Any]: + """Route the action to the appropriate toolkit tool.""" + action = command.get("action") + network = command.get("network", "testnet") + params = command.copy() + params.pop("action", None) + params.pop("network", None) + + try: + if action == "get_address_info": + tool = GetAddressInfoTool() + result = await tool.execute(address=params.get("address"), network=network) + + elif action == "get_balance": # Alias for address info + tool = GetAddressInfoTool() + result = await tool.execute(address=params.get("address"), network=network) + + elif action == "get_block": + height = params.get("height") + if height is not None: + tool = GetBlockByHeightTool() + result = await tool.execute(block_height=int(height), network=network) + else: + tool = GetRecentBlocksInfoTool() + result = await tool.execute(Limit=params.get("limit", 5), network=network) + + elif action == "get_transaction": + tool = GetRawTransactionByTransactionHashTool() + result = await tool.execute(tx_hash=params.get("hash"), network=network) + + elif action == "get_nep17_transfers": + tool = GetNep17TransferByAddressTool() + result = await tool.execute( + address=params.get("address"), + Limit=params.get("limit", 10), + Skip=params.get("skip", 0), + network=network + ) + + elif action == "get_nep11_transfers": + tool = GetNep11TransferByAddressTool() + result = await tool.execute( + address=params.get("address"), + Limit=params.get("limit", 10), + Skip=params.get("skip", 0), + network=network + ) + + elif action == "get_contract": + tool = GetContractByHashTool() + result = await tool.execute(contract_hash=params.get("hash"), network=network) + + elif action == "get_contract_list": + tool = GetContractListByNameTool() + result = await tool.execute( + contract_name=params.get("name") or params.get("contract_name"), + Limit=params.get("limit", 10), + network=network + ) + + elif action == "get_committee": + tool = GetCommitteeInfoTool() + result = await tool.execute(network=network) + + elif action == "get_candidates": + tool = GetCandidateCountTool() + result = await tool.execute(network=network) + + elif action == "get_logs": + tool = GetApplicationLogTool() + result = await tool.execute(tx_hash=params.get("hash"), network=network) + + elif action == "get_asset_info": + tool = GetAssetInfoByNameTool() + result = await tool.execute( + asset_name=params.get("name"), + Limit=params.get("limit", 5), + network=network + ) + + else: + return {"status": "error", "message": f"Unknown action: {action}"} + + return { + "status": "success", + "action": action, + "data": result.output if hasattr(result, "output") else str(result) + } + + except Exception as e: + return {"status": "error", "message": str(e)} + + +async def main(): + """Main entry point.""" + try: + input_text = sys.stdin.read().strip() + if not input_text: + print(json.dumps({"status": "error", "message": "No input provided"})) + return + + command = json.loads(input_text) + except json.JSONDecodeError: + # Fallback: simple text input treated as balance check + command = {"action": "get_address_info", "address": input_text} + + result = await run_query(command) + print(json.dumps(result, indent=2, ensure_ascii=False)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/skills/research/SKILL.md b/examples/skills/research/SKILL.md index 5ab7505..fc1429f 100644 --- a/examples/skills/research/SKILL.md +++ b/examples/skills/research/SKILL.md @@ -44,13 +44,18 @@ parameters: type: list required: false description: Preferred sources to use for research -prerequisites: - tools: - - tavily_search - env_vars: [] - skills: [] composable: true persist_state: true + +scripts: + enabled: true + working_directory: ./scripts + definitions: + - name: tavily_search + description: Search the web for general information using Tavily API + type: python + file: tavily_search.py + timeout: 30 --- # Research Skill diff --git a/examples/skills/research/scripts/tavily_search.py b/examples/skills/research/scripts/tavily_search.py new file mode 100644 index 0000000..1e08a5e --- /dev/null +++ b/examples/skills/research/scripts/tavily_search.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +""" +General Search Script for Research Skill +This script uses Tavily API to search for information on any topic. +""" + +import os +import sys +import json +from typing import Optional + +try: + from tavily import TavilyClient +except ImportError: + print(json.dumps({ + "status": "error", + "message": "Tavily package not installed. Run: pip install tavily-python" + })) + sys.exit(1) + + +def search(query: str, max_results: int = 5) -> dict: + """ + Search using Tavily API. + """ + api_key = os.environ.get("TAVILY_API_KEY") + if not api_key: + return { + "status": "error", + "message": "TAVILY_API_KEY environment variable not set" + } + + try: + client = TavilyClient(api_key=api_key) + + # General search without domain restrictions + response = client.search( + query=query, + search_depth="advanced", + max_results=max_results + ) + + # Format results + results = [] + for item in response.get("results", []): + results.append({ + "title": item.get("title", ""), + "url": item.get("url", ""), + "content": item.get("content", ""), + "score": item.get("score", 0) + }) + + return { + "status": "success", + "query": query, + "results": results, + "answer": response.get("answer", "") + } + + except Exception as e: + return { + "status": "error", + "message": str(e) + } + + +def main(): + """Main entry point.""" + # Read query from stdin + query = sys.stdin.read().strip() + + if not query: + print(json.dumps({ + "status": "error", + "message": "No query provided. Pass query via stdin." + })) + return + + # Perform search + result = search(query) + + # Output JSON result + print(json.dumps(result, indent=2, ensure_ascii=False)) + + +if __name__ == "__main__": + main() + diff --git a/examples/web3_research_skill_agent_demo.py b/examples/web3_research_skill_agent_demo.py index 63400f5..1b1de17 100644 --- a/examples/web3_research_skill_agent_demo.py +++ b/examples/web3_research_skill_agent_demo.py @@ -62,6 +62,7 @@ def __init__(self, **kwargs): kwargs.setdefault('description', 'AI agent specialized in Web3 and cryptocurrency research (skill-based)') kwargs.setdefault('system_prompt', self._get_system_prompt()) kwargs.setdefault('max_steps', 10) + kwargs.setdefault('_default_timeout', 120.0) # Configure skill paths to include examples/skills kwargs.setdefault('skill_paths', [EXAMPLES_SKILLS_PATH]) @@ -112,10 +113,10 @@ async def initialize(self, __context=None): skills = self.list_skills() logger.info(f"Available skills: {skills}") - # Activate web3-research skill to get access to tavily_search script - if "web3-research" in skills: - await self.activate_skill("web3-research") - logger.info("Activated web3-research skill with tavily_search script") + # # Activate web3-research skill to get access to tavily_search script + # if "web3-research" in skills: + # await self.activate_skill("web3-research") + # logger.info("Activated web3-research skill with tavily_search script") async def research(self, query: str) -> str: """ @@ -150,8 +151,6 @@ async def demo_basic_research(): # Create agent with OpenAI agent = Web3ResearchSkillAgent( llm=ChatBot( - llm_provider="openai", - model_name="gpt-4o-mini" ), auto_trigger_skills=True, max_auto_skills=2 @@ -186,8 +185,6 @@ async def demo_with_skill_info(): agent = Web3ResearchSkillAgent( llm=ChatBot( - llm_provider="openai", - model_name="gpt-4o-mini" ), auto_trigger_skills=True ) @@ -243,8 +240,6 @@ async def demo_interactive(): agent = Web3ResearchSkillAgent( llm=ChatBot( - llm_provider="openai", - model_name="gpt-4o-mini" ), auto_trigger_skills=True ) @@ -298,8 +293,8 @@ async def main(): print("Tavily search will not work. Get your API key from https://tavily.com") print() - if not (os.getenv("OPENAI_API_KEY") or os.getenv("ANTHROPIC_API_KEY")): - print("Error: No LLM API key found. Set OPENAI_API_KEY or ANTHROPIC_API_KEY.") + if not (os.getenv("OPENAI_API_KEY") or os.getenv("ANTHROPIC_API_KEY")or os.getenv("GEMINI_API_KEY") or os.getenv("OPENROUTER_API_KEY")): + print("Error: No LLM API key found. Set OPENAI_API_KEY or ANTHROPIC_API_KEY,GEMINI_API_KEY or OPENROUTER_API_KEY.") sys.exit(1) # Run demos diff --git a/spoon_ai/agents/base.py b/spoon_ai/agents/base.py index 57a09e8..d76c776 100644 --- a/spoon_ai/agents/base.py +++ b/spoon_ai/agents/base.py @@ -112,9 +112,9 @@ def __init__(self, **kwargs): self._max_history = 100 # Timeout configurations - self._default_timeout = 30.0 - self._state_transition_timeout = 5.0 - self._memory_operation_timeout = 10.0 + self._default_timeout = kwargs.get("_default_timeout", 30.0) + self._state_transition_timeout = kwargs.get("_state_transition_timeout", 5.0) + self._memory_operation_timeout = kwargs.get("_memory_operation_timeout", 10.0) # Concurrency control self._active_operations = set() diff --git a/spoon_ai/agents/toolcall.py b/spoon_ai/agents/toolcall.py index c4529c0..1d996ed 100644 --- a/spoon_ai/agents/toolcall.py +++ b/spoon_ai/agents/toolcall.py @@ -43,6 +43,9 @@ class ToolCallAgent(ReActAgent): # Track last tool error for higher-level fallbacks last_tool_error: Optional[str] = Field(default=None, exclude=True) + # Reduced default timeout as per user request (blockchain operations will focus on submission) + _default_timeout: float = 120.0 + # MCP Tools Caching mcp_tools_cache: Optional[List[MCPTool]] = Field(default=None, exclude=True) mcp_tools_cache_timestamp: Optional[float] = Field(default=None, exclude=True) @@ -163,18 +166,16 @@ def convert_mcp_tool(tool: MCPTool) -> dict: pass # If check fails, use default timeout # Bound LLM tool selection time to avoid step-level timeouts - # Increase timeout for image/document processing (especially Data URLs and PDFs which can be slower) - base_timeout = max(20.0, min(60.0, getattr(self, '_default_timeout', 30.0) - 5.0)) + # Increase timeout for image/document processing + base_timeout = max(30.0, min(120.0, getattr(self, '_default_timeout', 60.0) - 5.0)) if has_images or has_documents: # Increase timeout for image/document processing - # Documents (especially PDFs) can be large and require more processing time - # Large PDFs (4MB+) may need up to 180 seconds (3 minutes) for processing if has_documents: llm_timeout = 180.0 # 3 minutes for large PDF processing elif has_images: - llm_timeout = min(120.0, base_timeout * 2) # 2 minutes for images + llm_timeout = max(120.0, base_timeout * 2) # 2 minutes for images else: - llm_timeout = min(60.0, base_timeout * 2) + llm_timeout = max(60.0, base_timeout * 2) content_type = "images and documents" if (has_images and has_documents) else ("images" if has_images else "documents") logger.debug(f"Detected {content_type}, increased timeout to {llm_timeout}s for processing") @@ -247,10 +248,19 @@ def convert_mcp_tool(tool: MCPTool) -> dict: async def run(self, request: Optional[str] = None) -> str: """Override run method to handle finish_reason termination specially.""" - if self.state != AgentState.IDLE: - raise RuntimeError(f"Agent {self.name} is not in the IDLE state") - - self.state = AgentState.RUNNING + # Use run lock to prevent multiple concurrent run() calls (thread-safe) + try: + async with asyncio.timeout(1.0): # Quick timeout for run lock + async with self._run_lock: + # Double-check state after acquiring lock + if self.state != AgentState.IDLE: + raise RuntimeError( + f"Agent {self.name} is not in the IDLE state (currently: {self.state})" + ) + # Set running state atomically + self.state = AgentState.RUNNING + except asyncio.TimeoutError: + raise RuntimeError(f"Agent {self.name} is busy - another run() operation is in progress") if request is not None: await self.add_message("user", request) diff --git a/spoon_ai/memory/short_term_manager.py b/spoon_ai/memory/short_term_manager.py index 1df12f6..430ff10 100644 --- a/spoon_ai/memory/short_term_manager.py +++ b/spoon_ai/memory/short_term_manager.py @@ -148,9 +148,10 @@ async def _apply_tool_call_dependencies( # Iteratively add dependencies until stable current_indices = set(keep_indices) - + while True: added_any = False + dropped_any = False # 1. Tool -> Assistant (ensure parent is kept) for idx in list(current_indices): @@ -164,18 +165,36 @@ async def _apply_tool_call_dependencies( added_any = True # 2. Assistant -> Tools (ensure all responses are kept) + # CRITICAL: If an assistant message has tool calls, but some of those tool calls + # don't have responses in the ENTIRE message list (e.g. due to timeout), + # we MUST drop the assistant message because OpenAI will reject it. for idx in list(current_indices): message = messages[idx] if message.role != "assistant" or not message.tool_calls: continue tool_indices = self._find_tool_indices_for_assistant(messages, idx) + + # Check if all tool calls have a corresponding tool message in the source 'messages' list + if len(tool_indices) < len(message.tool_calls): + # Orphaned tool calls detected! + # This assistant message is invalid for the API. + logger.warning(f"Dropping assistant message at index {idx} because it has {len(message.tool_calls)} tool calls but only {len(tool_indices)} responses found in memory.") + current_indices.remove(idx) + # Also remove any partial responses already in keep_indices + for t_idx in tool_indices: + if t_idx in current_indices: + current_indices.remove(t_idx) + dropped_any = True + continue + + # All responses exist in source list, ensure they are all in current_indices for t_idx in tool_indices: if t_idx not in current_indices: current_indices.add(t_idx) added_any = True - if not added_any: + if not added_any and not dropped_any: break # Check budget if specified @@ -189,14 +208,14 @@ async def _apply_tool_call_dependencies( proposed_messages = [messages[i] for i in sorted(current_indices)] token_cost = await self.token_counter.count_tokens(proposed_messages, model) - + if token_cost > max_tokens: # If adding dependencies broke the budget, we have to start dropping. # Simplest strategy: if a group (Assistant + Tools) doesn't fit, drop the whole group # starting from the oldest. # This is a bit advanced, so for now we'll just log it. logger.warning(f"Tool dependency resolution exceeded token budget ({token_cost} > {max_tokens})") - + return current_indices async def trim_messages(