diff --git a/src/cai/cli.py b/src/cai/cli.py index b7ae1723..a5dc6185 100644 --- a/src/cai/cli.py +++ b/src/cai/cli.py @@ -305,6 +305,67 @@ def run_cai_cli(starting_agent, context_variables=None, max_turns=float('inf')): Total = time.time() - START_TIME idle_time += time.time() - idle_start_time + + # NEW: Clean up any pending tool calls before exiting + try: + # Access the _Converter directly to clean up any pending tool calls + from cai.sdk.agents.models.openai_chatcompletions import _Converter + + # Check if any tool calls are pending (have been issued but don't have responses) + pending_calls = [] + if hasattr(_Converter, 'recent_tool_calls'): + for call_id, call_info in list(_Converter.recent_tool_calls.items()): + # Check if this tool call has a corresponding response in message_history + tool_response_exists = False + for msg in message_history: + if msg.get("role") == "tool" and msg.get("tool_call_id") == call_id: + tool_response_exists = True + break + + # If no tool response exists, create a synthetic one + if not tool_response_exists: + # First ensure there's a matching assistant message with this tool call + assistant_exists = False + for msg in message_history: + if (msg.get("role") == "assistant" and + msg.get("tool_calls") and + any(tc.get("id") == call_id for tc in msg.get("tool_calls", []))): + assistant_exists = True + break + + # Add assistant message if needed + if not assistant_exists: + tool_call_msg = { + "role": "assistant", + "content": None, + "tool_calls": [{ + "id": call_id, + "type": "function", + "function": { + "name": call_info.get('name', 'unknown_function'), + "arguments": call_info.get('arguments', '{}') + } + }] + } + add_to_message_history(tool_call_msg) + + # Add a synthetic tool response + tool_msg = { + "role": "tool", + "tool_call_id": call_id, + "content": "Operation interrupted by user (Keyboard Interrupt during shutdown)" + } + add_to_message_history(tool_msg) + pending_calls.append(call_info.get('name', 'unknown')) + + # Apply message list fixes + if pending_calls: + from cai.util import fix_message_list + message_history[:] = fix_message_list(message_history) + print(f"\033[93mCleaned up {len(pending_calls)} pending tool calls before exit\033[0m") + except Exception: + pass + try: # Get more accurate active and idle time measurements from the timer functions from cai.util import get_active_time_seconds, get_idle_time_seconds, COST_TRACKER @@ -703,6 +764,63 @@ def run_cai_cli(starting_agent, context_variables=None, max_turns=float('inf')): except KeyboardInterrupt: print("\n\033[91mKeyboard interrupt detected\033[0m") + + # NEW: Handle pending tool calls to prevent errors on next iteration + try: + # Access the _Converter directly to clean up any pending tool calls + from cai.sdk.agents.models.openai_chatcompletions import _Converter + + # Check if any tool calls are pending (have been issued but don't have responses) + if hasattr(_Converter, 'recent_tool_calls'): + for call_id, call_info in list(_Converter.recent_tool_calls.items()): + # Check if this tool call has a corresponding response in message_history + tool_response_exists = False + for msg in message_history: + if msg.get("role") == "tool" and msg.get("tool_call_id") == call_id: + tool_response_exists = True + break + + # If no tool response exists, create a synthetic one + if not tool_response_exists: + # First ensure there's a matching assistant message with this tool call + assistant_exists = False + for msg in message_history: + if (msg.get("role") == "assistant" and + msg.get("tool_calls") and + any(tc.get("id") == call_id for tc in msg.get("tool_calls", []))): + assistant_exists = True + break + + # Add assistant message if needed + if not assistant_exists: + tool_call_msg = { + "role": "assistant", + "content": None, + "tool_calls": [{ + "id": call_id, + "type": "function", + "function": { + "name": call_info.get('name', 'unknown_function'), + "arguments": call_info.get('arguments', '{}') + } + }] + } + add_to_message_history(tool_call_msg) + + # Add a synthetic tool response + tool_msg = { + "role": "tool", + "tool_call_id": call_id, + "content": "Operation interrupted by user (Keyboard Interrupt)" + } + add_to_message_history(tool_msg) + + # Apply message list fixes + from cai.util import fix_message_list + message_history[:] = fix_message_list(message_history) + except Exception as cleanup_error: + print(f"\033[91mError cleaning up interrupted tools: {str(cleanup_error)}\033[0m") + pass except Exception as e: import traceback diff --git a/src/cai/sdk/agents/models/openai_chatcompletions.py b/src/cai/sdk/agents/models/openai_chatcompletions.py index 6b0e71bc..04de9fa8 100644 --- a/src/cai/sdk/agents/models/openai_chatcompletions.py +++ b/src/cai/sdk/agents/models/openai_chatcompletions.py @@ -348,17 +348,41 @@ class OpenAIChatCompletionsModel(Model): # Get token count estimate before API call for consistent counting estimated_input_tokens, _ = count_tokens_with_tiktoken(converted_messages) - response = await self._fetch_response( - system_instructions, - input, - model_settings, - tools, - output_schema, - handoffs, - span_generation, - tracing, - stream=False, - ) + try: + response = await self._fetch_response( + system_instructions, + input, + model_settings, + tools, + output_schema, + handoffs, + span_generation, + tracing, + stream=False, + ) + except KeyboardInterrupt: + # Handle KeyboardInterrupt during API call + # Make sure to clean up anything needed for proper state before allowing interrupt to propagate + + # If this call generated any tool calls, they were stored in _Converter.recent_tool_calls but + # we couldn't add them to message_history since we didn't get the response. + # We should generate synthetic responses to avoid broken message sequences. + + # Add synthetic tool output to prevent errors in next turn + if hasattr(_Converter, 'tool_outputs') and hasattr(_Converter, 'recent_tool_calls'): + # Add a placeholder response for any tool call generated during this interaction + # We don't know the actual tool calls, so we'll use what we know from timing + # Any tool call that was generated within the last 5 seconds is likely from this interaction + current_time = time.time() + for call_id, call_info in list(_Converter.recent_tool_calls.items()): + if 'start_time' in call_info and (current_time - call_info['start_time']) < 5.0: + # Add a placeholder output for this tool call + _Converter.tool_outputs[call_id] = "Operation interrupted by user (KeyboardInterrupt)" + + # Let the interrupt propagate up to end the current operation + stop_active_timer() + start_idle_timer() + raise if _debug.DONT_LOG_MODEL_DATA: logger.debug("Received model response") diff --git a/src/cai/sdk/agents/tool.py b/src/cai/sdk/agents/tool.py index a1a0241b..c1c16242 100644 --- a/src/cai/sdk/agents/tool.py +++ b/src/cai/sdk/agents/tool.py @@ -272,11 +272,6 @@ def function_tool( async def _on_invoke_tool(ctx: RunContextWrapper[Any], input: str) -> Any: try: return await _on_invoke_tool_impl(ctx, input) - except KeyboardInterrupt: - logger.info( - f"Tool {schema.name} execution was interrupted by the user (Ctrl+C)." - ) - return "execution was interrupted by the user (Ctrl+C)" except Exception as e: if failure_error_function is None: raise