diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -21,7 +21,7 @@ logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) -logger = logging.getLogger("g4f-smart-router") +logger = logging.getLogger("g4f-execution-engine") # ===================================================== # COOKIES @@ -62,18 +62,42 @@ def load_cookies() -> str: COOKIE_STATUS = load_cookies() +# ===================================================== +# EXECUTION STATE MACHINE +# ===================================================== +class ExecutionState: + IDLE = "idle" + PLANNING = "planning" + TOOL_PENDING = "tool_pending" + TOOL_EXECUTING = "tool_executing" + TOOL_DONE = "tool_done" + AWAITING_NEXT = "awaiting_next" + COMPLETED = "completed" + +class PendingToolCall: + def __init__(self, name: str, arguments: Dict, tool_id: str, reason: str = ""): + self.name = name + self.arguments = arguments + self.tool_id = tool_id + self.reason = reason + self.created_at = time.time() + self.attempts = 0 + self.max_attempts = 3 + + def to_dict(self) -> Dict: + return { + "name": self.name, + "arguments": self.arguments, + "tool_id": self.tool_id, + "reason": self.reason, + "created_at": self.created_at, + "attempts": self.attempts + } + # ===================================================== # PERSISTENT CONTEXT STORE -# يحفظ سياق المهام بين النماذج المختلفة ويمنع النسيان # ===================================================== class PersistentContextStore: - """ - مخزن السياق الدائم - يحفظ: - 1. المهمة الأصلية (Original Task) - 2. الخطوات المكتملة (Completed Steps) - 3. نتائج الأدوات (Tool Results) - 4. الحالة الحالية (Current State) - """ def __init__(self): self._store: Dict[str, Dict] = {} self._lock = threading.Lock() @@ -83,14 +107,20 @@ class PersistentContextStore: if session_id not in self._store: self._store[session_id] = { "original_task": "", + "task_breakdown": [], "completed_steps": [], "tool_results": [], "pending_tool_calls": [], - "current_state": "idle", + "executed_tool_calls": [], + "current_state": ExecutionState.IDLE, + "next_required_action": "", "created_at": time.time(), "last_updated": time.time(), "message_count": 0, "provider_history": [], + "last_model_response": "", + "described_but_not_executed": [], + "consecutive_description_count": 0, } return self._store[session_id] @@ -105,13 +135,30 @@ class PersistentContextStore: with self._lock: if session_id not in self._store: self.get_or_create_session(session_id) + # إزالة من المعلّقة + self._store[session_id]["pending_tool_calls"] = [ + p for p in self._store[session_id].get("pending_tool_calls", []) + if p.get("tool_id") != tool_id + ] + # إضافة للمنفَّذة + self._store[session_id]["executed_tool_calls"].append({ + "tool_name": tool_name, + "tool_id": tool_id, + "timestamp": time.time() + }) + # حفظ النتيجة self._store[session_id]["tool_results"].append({ "tool_name": tool_name, "tool_id": tool_id, "result": result, "timestamp": time.time() }) + # إعادة ضبط عداد الوصف + self._store[session_id]["consecutive_description_count"] = 0 + self._store[session_id]["described_but_not_executed"] = [] self._store[session_id]["last_updated"] = time.time() + if self._store[session_id].get("current_state") == ExecutionState.TOOL_EXECUTING: + self._store[session_id]["current_state"] = ExecutionState.TOOL_DONE def add_completed_step(self, session_id: str, step: str): with self._lock: @@ -123,16 +170,142 @@ class PersistentContextStore: }) self._store[session_id]["last_updated"] = time.time() - def get_context_summary(self, session_id: str) -> str: + def register_pending_tool(self, session_id: str, tool_call: PendingToolCall): + with self._lock: + if session_id not in self._store: + self.get_or_create_session(session_id) + existing_ids = [ + p.get("tool_id") + for p in self._store[session_id].get("pending_tool_calls", []) + ] + if tool_call.tool_id not in existing_ids: + self._store[session_id]["pending_tool_calls"].append(tool_call.to_dict()) + self._store[session_id]["current_state"] = ExecutionState.TOOL_PENDING + self._store[session_id]["last_updated"] = time.time() + + def get_pending_tools(self, session_id: str) -> List[Dict]: + with self._lock: + if session_id not in self._store: + return [] + return self._store[session_id].get("pending_tool_calls", []) + + def increment_description_count(self, session_id: str, response_text: str) -> int: + """ + يزيد عداد "الوصف بدون تنفيذ" ويعيد العدد الحالي. + هذا يُصلح مشكلة "المشاهد الذكي" بتتبع دقيق لعدد المرات. + """ + with self._lock: + if session_id not in self._store: + self.get_or_create_session(session_id) + self._store[session_id]["described_but_not_executed"].append({ + "response_preview": response_text[:300], + "timestamp": time.time() + }) + count = self._store[session_id].get("consecutive_description_count", 0) + 1 + self._store[session_id]["consecutive_description_count"] = count + self._store[session_id]["last_updated"] = time.time() + return count + + def get_execution_directive(self, session_id: str) -> str: """ - يولّد ملخصاً للسياق يُحقن في كل رسالة - هذا هو سر منع النسيان - السياق يُعاد حقنه في كل مرة + *** القلب الحقيقي للحل *** + يولّد توجيهاً إجبارياً يحدد ما يجب فعله الآن. + هذا يحل مشكلة "غياب الدفعة الأخيرة". """ with self._lock: if session_id not in self._store: return "" ctx = self._store[session_id] + parts = [] + state = ctx.get("current_state", ExecutionState.IDLE) + pending = ctx.get("pending_tool_calls", []) + executed = ctx.get("executed_tool_calls", []) + desc_count = ctx.get("consecutive_description_count", 0) + tool_results = ctx.get("tool_results", []) + + # ===== الحالة 1: أداة معلّقة - أعلى أولوية ===== + if pending and state == ExecutionState.TOOL_PENDING: + pending_tool = pending[0] + tool_name = pending_tool.get("name", "unknown") + tool_args = pending_tool.get("arguments", {}) + tool_id = pending_tool.get("tool_id", "") + reason = pending_tool.get("reason", "") + + parts.append("## ⚡ MANDATORY IMMEDIATE ACTION") + parts.append("") + parts.append( + f"Tool `{tool_name}` was registered as pending but NOT executed." + ) + if reason: + parts.append(f"Reason: {reason}") + parts.append("") + parts.append("**COPY THIS EXACTLY INTO YOUR RESPONSE:**") + parts.append("") + parts.append("") + call_obj = {"name": tool_name, "arguments": tool_args} + if tool_id: + call_obj["id"] = tool_id + parts.append(json.dumps(call_obj, ensure_ascii=False, indent=2)) + parts.append("") + parts.append("") + parts.append("NO other text is needed. Just the tool_call block above.") + return "\n".join(parts) + + # ===== الحالة 2: وصف متكرر بدون تنفيذ ===== + if desc_count >= 1: + parts.append("## 🚨 CRITICAL: EXECUTION FAILURE PATTERN DETECTED") + parts.append("") + parts.append( + f"You have described actions {desc_count} time(s) without executing them." + ) + parts.append("") + parts.append("**THE ONLY ACCEPTABLE RESPONSE FORMAT:**") + parts.append("") + parts.append("```") + parts.append("") + parts.append('{"name": "TOOL_NAME", "arguments": {ACTUAL_ARGUMENTS}}') + parts.append("") + parts.append("```") + parts.append("") + parts.append("**FORBIDDEN PATTERNS (will be rejected):**") + parts.append('❌ "I will create..." → FORBIDDEN') + parts.append('❌ "I\'m going to use..." → FORBIDDEN') + parts.append('❌ "Let me write..." → FORBIDDEN') + parts.append('❌ "I\'ll use the tool..." → FORBIDDEN') + parts.append('❌ "سأقوم بـ..." → FORBIDDEN') + parts.append('❌ "سأستخدم أداة..." → FORBIDDEN') + parts.append("") + parts.append("**EXECUTE NOW. No descriptions. No explanations.**") + return "\n".join(parts) + + # ===== الحالة 3: أداة منفَّذة، تحقق من الاكتمال ===== + if tool_results and state == ExecutionState.TOOL_DONE: + last_result = tool_results[-1] + tool_name = last_result.get("tool_name", "") + next_action = ctx.get("next_required_action", "") + + parts.append(f"## ✅ Tool `{tool_name}` Completed Successfully") + parts.append("") + if next_action: + parts.append(f"**Next Required Action:** {next_action}") + parts.append("Execute it now using the appropriate tool.") + else: + parts.append( + "Determine if the original task is now complete. " + "If more tools are needed, call them now. " + "If done, provide a brief confirmation." + ) + return "\n".join(parts) + + return "" + + def get_context_summary(self, session_id: str) -> str: + with self._lock: + if session_id not in self._store: + return "" + ctx = self._store[session_id] + parts = [] if ctx.get("original_task"): @@ -140,31 +313,49 @@ class PersistentContextStore: if ctx.get("completed_steps"): steps_text = "\n".join( - f" - {s['step']}" for s in ctx["completed_steps"][-10:] + f" ✅ {s['step']}" + for s in ctx["completed_steps"][-10:] ) parts.append(f"## Completed Steps\n{steps_text}") + if ctx.get("executed_tool_calls"): + exec_text = "\n".join( + f" 🔧 {e['tool_name']} (id: {e['tool_id'][:12]}...)" + for e in ctx["executed_tool_calls"][-5:] + ) + parts.append(f"## Tools Already Executed\n{exec_text}") + if ctx.get("tool_results"): - # آخر 5 نتائج فقط لتجنب الحجم الكبير - recent_results = ctx["tool_results"][-5:] + recent_results = ctx["tool_results"][-3:] results_text = "\n".join( - f" - [{r['tool_name']}]: {r['result'][:300]}..." - if len(r['result']) > 300 - else f" - [{r['tool_name']}]: {r['result']}" + f" - [{r['tool_name']}]: " + f"{r['result'][:300]}{'...' if len(r['result']) > 300 else ''}" for r in recent_results ) - parts.append(f"## Tool Results So Far\n{results_text}") + parts.append(f"## Tool Results Available\n{results_text}") + + if ctx.get("pending_tool_calls"): + pending_text = "\n".join( + f" ⏳ {p['name']} (NOT YET EXECUTED)" + for p in ctx["pending_tool_calls"] + ) + parts.append(f"## ⚠️ PENDING TOOLS (Must Execute)\n{pending_text}") - if ctx.get("current_state") and ctx["current_state"] != "idle": - parts.append(f"## Current State\n{ctx['current_state']}") + # التوجيه التنفيذي - الأهم دائماً + directive = self.get_execution_directive(session_id) + if directive: + parts.append(directive) if not parts: return "" - return "# Task Context (DO NOT FORGET)\n" + "\n\n".join(parts) + return ( + "# Task Context & Execution State\n" + "# (This is your memory - use it to continue the task)\n\n" + + "\n\n".join(parts) + ) def cleanup_old_sessions(self, max_age_hours: int = 24): - """تنظيف الجلسات القديمة""" with self._lock: now = time.time() old_sessions = [ @@ -177,12 +368,10 @@ class PersistentContextStore: logger.info(f"[Context] Cleaned {len(old_sessions)} old sessions") def extract_task_from_messages(self, messages: List[Dict]) -> str: - """استخراج المهمة الأصلية من أول رسالة مستخدم""" for msg in messages: if msg.get("role") == "user": content = msg.get("content", "") if isinstance(content, str) and len(content) > 10: - # أول 500 حرف من أول رسالة مستخدم = المهمة الأصلية return content[:500] elif isinstance(content, list): for item in content: @@ -192,12 +381,10 @@ class PersistentContextStore: return text[:500] return "" - -# المخزن العالمي للسياق CONTEXT_STORE = PersistentContextStore() # ===================================================== -# CACHE محسن +# CACHE # ===================================================== class TTLCache: def __init__(self, max_size: int = 100, ttl_seconds: int = 300): @@ -315,10 +502,10 @@ def clean_stream(chunk): pass if '\\' in chunk: chunk = chunk.replace('\\n', '\n') - if '\\r' in chunk: - chunk = chunk.replace('\\r', '\r') - if '\\t' in chunk: - chunk = chunk.replace('\\t', ' ') + if '\\r' in chunk: + chunk = chunk.replace('\\r', '\r') + if '\\t' in chunk: + chunk = chunk.replace('\\t', ' ') return chunk return str(chunk) except Exception as e: @@ -326,7 +513,7 @@ def clean_stream(chunk): return "" # ===================================================== -# استخراج النص من content +# استخراج النص # ===================================================== def extract_text_from_content(content) -> str: if isinstance(content, str): @@ -354,10 +541,7 @@ def extract_text_from_content(content) -> str: result_str = tool_content else: result_str = str(tool_content) - # صيغة واضحة ومنظمة لنتيجة الأداة - parts.append( - f"[Tool Result for {tool_use_id}]:\n{result_str}" - ) + parts.append(f"[Tool Result for {tool_use_id}]:\n{result_str}") elif item.get("type") == "tool_use": tool_name = item.get("name", "unknown") tool_input = item.get("input", {}) @@ -376,12 +560,94 @@ def extract_text_from_content(content) -> str: return "" # ===================================================== -# TOOL CALL PARSER +# RESPONSE ANALYZER - محسّن جذرياً +# ===================================================== +class ResponseAnalyzer: + """ + يحلل رد النموذج لاكتشاف: + 1. هل وصف فعلاً بدون تنفيذ؟ + 2. هل يحتوي على tool call فعلي؟ + 3. ما هي الأداة المقصودة؟ + """ + + # أنماط "الوصف بدون تنفيذ" - شاملة ودقيقة + DESCRIPTION_PHRASES = [ + # عربي + r"سأقوم\s+ب", + r"سأستخدم\s+(?:أداة|الأداة|tool)", + r"سأكتب\s+(?:الكود|الملف|الآن)", + r"سأنشئ\s+(?:ملف|الملف|كود)", + r"سأحفظ\s+(?:الملف|الكود|الآن)", + r"سأبدأ\s+(?:الآن|بـ)", + r"دعني\s+(?:أنشئ|أكتب|أستخدم|أحفظ)", + # إنجليزي - I will + r"I\s+will\s+(?:now\s+)?(?:create|write|save|use|call|make|build|generate|execute)", + r"I\s+will\s+(?:now\s+)?(?:use|call)\s+the\s+\w+\s+tool", + # إنجليزي - I'm going to + r"I(?:'m|'m)\s+going\s+to\s+(?:create|write|save|use|call|make|build|generate)", + # إنجليزي - Let me + r"Let\s+me\s+(?:create|write|save|use|call|make|build|generate|now)", + # إنجليزي - Now I'll + r"Now\s+I(?:'ll|'m\s+going\s+to)\s+(?:create|write|save|use|call|make|build)", + # إنجليزي - I'll + r"I(?:'ll|'ll)\s+(?:now\s+)?(?:use|call)\s+the\s+\w+\s+(?:tool|function)", + r"I(?:'ll|'ll)\s+(?:now\s+)?(?:create|write|save|make|build|generate)", + # إنجليزي - using the tool + r"using\s+the\s+(?:write_file|create_file|save_file|search|browse|read_file)\s+tool", + # إنجليزي - To create/write + r"To\s+(?:create|write|save)\s+this\s+(?:file|code|content)", + # إنجليزي - I need to use + r"I\s+need\s+to\s+(?:use|call)\s+the\s+\w+\s+(?:tool|function)", + # الوصف في code block + r"```json\n\{[^}]*\"name\"\s*:", + ] + + @classmethod + def analyze(cls, response: str, available_tools: List[Dict] = None) -> Dict: + result = { + "has_actual_tool_call": False, + "has_description_without_execution": False, + "described_actions": [], + "needs_execution_push": False, + "tool_calls_found": [], + "intended_tool_name": None, + } + + # التحقق من وجود tool call فعلي + clean_text, tool_calls = ToolCallParser.parse_tool_calls(response, available_tools) + result["has_actual_tool_call"] = len(tool_calls) > 0 + result["tool_calls_found"] = tool_calls + + # البحث عن عبارات الوصف + descriptions_found = [] + for pattern in cls.DESCRIPTION_PHRASES: + matches = re.findall(pattern, response, re.IGNORECASE) + descriptions_found.extend(matches) + + result["described_actions"] = descriptions_found + + # الحكم النهائي + if descriptions_found and not result["has_actual_tool_call"]: + result["has_description_without_execution"] = True + result["needs_execution_push"] = True + + # محاولة استخراج اسم الأداة المقصودة + if available_tools: + for tool in available_tools: + tool_name = tool.get("name", "") + if tool_name and tool_name.lower() in response.lower(): + result["intended_tool_name"] = tool_name + break + + return result + +# ===================================================== +# TOOL CALL PARSER - محسّن مع تسامح أعلى # ===================================================== class ToolCallParser: """ - يكتشف ويحلل وسوم Tool Call من استجابة النموذج. - يدعم أنماطاً متعددة مع regex محسّن يقبل المسافات والأسطر الجديدة. + يكتشف ويحلل وسوم Tool Call. + محسّن للتعامل مع التنسيقات المختلفة والأخطاء الطفيفة. """ START_MARKERS = [ @@ -399,12 +665,10 @@ class ToolCallParser: @staticmethod def generate_tool_id() -> str: - """توليد معرف فريد للأداة بصيغة Anthropic""" return f"toolu_{uuid.uuid4().hex[:24]}" @classmethod def might_contain_tool_call(cls, text: str) -> bool: - """فحص سريع: هل النص قد يحتوي على بداية tool call؟""" text_stripped = text.strip().lower() for marker in cls.START_MARKERS: if marker.lower() in text_stripped: @@ -419,11 +683,11 @@ class ToolCallParser: text: str, available_tools: Optional[List[Dict]] = None ) -> Tuple[str, List[Dict]]: - """تحليل النص واستخراج tool calls منه.""" tool_calls = [] clean_text = text - # ---- النمط 1: JSON ---- + # النمط 1: JSON + # مرن جداً - يتحمل مسافات وأسطر إضافية pattern1 = re.compile( r'\s*\s*(\{.*?\})\s*\s*', re.DOTALL | re.IGNORECASE @@ -431,13 +695,15 @@ class ToolCallParser: for match in pattern1.finditer(text): try: raw_json = match.group(1).strip() + # تنظيف JSON من الأخطاء الشائعة + raw_json = cls._clean_json_string(raw_json) parsed = json.loads(raw_json) tool_call = cls._normalize_tool_call(parsed, available_tools) if tool_call: tool_calls.append(tool_call) clean_text = clean_text.replace(match.group(0), " ", 1) except json.JSONDecodeError as e: - logger.warning(f"[Parser] Failed to parse tool_call JSON: {e}") + logger.warning(f"[Parser] JSON parse error: {e}") fixed = cls._try_fix_json(match.group(1).strip()) if fixed: tool_call = cls._normalize_tool_call(fixed, available_tools) @@ -445,7 +711,7 @@ class ToolCallParser: tool_calls.append(tool_call) clean_text = clean_text.replace(match.group(0), " ", 1) - # ---- النمط 2: ```tool_call\nJSON\n``` ---- + # النمط 2: ```tool_call\nJSON\n``` if not tool_calls: pattern2 = re.compile( r'```(?:tool_call|json)?\s*\n?\s*(\{.*?"(?:name|function)".*?\})\s*\n?\s*```', @@ -454,6 +720,7 @@ class ToolCallParser: for match in pattern2.finditer(text): try: raw_json = match.group(1).strip() + raw_json = cls._clean_json_string(raw_json) parsed = json.loads(raw_json) tool_call = cls._normalize_tool_call(parsed, available_tools) if tool_call: @@ -462,7 +729,7 @@ class ToolCallParser: except json.JSONDecodeError: pass - # ---- النمط 3: JSON ---- + # النمط 3: JSON if not tool_calls: pattern3 = re.compile( r'\s*\s*(\{.*?\})\s*\s*', @@ -486,7 +753,7 @@ class ToolCallParser: except json.JSONDecodeError: pass - # ---- النمط 4: [TOOL_CALL]...[/TOOL_CALL] ---- + # النمط 4: [TOOL_CALL]...[/TOOL_CALL] if not tool_calls: pattern4 = re.compile( r'\s*\[TOOL_CALL\]\s*(.*?)\s*\[/TOOL_CALL\]\s*', @@ -518,7 +785,7 @@ class ToolCallParser: except json.JSONDecodeError: pass - # ---- النمط 5: ✿FUNCTION✿ ---- + # النمط 5: ✿FUNCTION✿ if not tool_calls: pattern5 = re.compile( r'✿FUNCTION✿:\s*(\w+)\s*\n✿ARGS✿:\s*(\{.*?\})\s*(?:\n✿RESULT✿)?', @@ -539,7 +806,7 @@ class ToolCallParser: except json.JSONDecodeError: pass - # ---- النمط 6: JSON مباشر يحتوي tool_calls ---- + # النمط 6: JSON مباشر يحتوي tool_calls if not tool_calls: try: json_pattern = re.compile( @@ -553,7 +820,10 @@ class ToolCallParser: for tc in parsed["tool_calls"]: func_data = tc.get("function", tc) name = func_data.get("name", "") - args = func_data.get("arguments", func_data.get("input", {})) + args = func_data.get( + "arguments", + func_data.get("input", {}) + ) if isinstance(args, str): try: args = json.loads(args) @@ -567,7 +837,7 @@ class ToolCallParser: "input": args } tool_calls.append(tool_call) - clean_text = clean_text.replace(jm.group(0), " ", 1) + clean_text = clean_text.replace(jm.group(0), " ", 1) except json.JSONDecodeError: pass except Exception: @@ -575,17 +845,30 @@ class ToolCallParser: clean_text = clean_text.strip() clean_text = re.sub(r'\n{3,}', '\n\n', clean_text) - clean_text = re.sub(r' +', ' ', clean_text) - + clean_text = re.sub(r' +', ' ', clean_text) return clean_text, tool_calls + @classmethod + def _clean_json_string(cls, json_str: str) -> str: + """ + تنظيف JSON من الأخطاء الشائعة. + هذا يحل مشكلة "ضياع الأوامر في الترجمة". + """ + # إزالة trailing commas + json_str = re.sub(r',\s*([}\]])', r'\1', json_str) + # إصلاح علامات الاقتباس المنحنية + json_str = json_str.replace('\u201c', '"').replace('\u201d', '"') + json_str = json_str.replace('\u2018', "'").replace('\u2019', "'") + # إزالة BOM + json_str = json_str.lstrip('\ufeff') + return json_str + @classmethod def _normalize_tool_call( cls, parsed: Dict, available_tools: Optional[List[Dict]] = None ) -> Optional[Dict]: - """توحيد صيغة tool call من أشكال مختلفة إلى صيغة Anthropic""" name = None arguments = {} @@ -595,11 +878,11 @@ class ToolCallParser: elif "name" in parsed: name = parsed["name"] arguments = ( - parsed.get("arguments") or - parsed.get("parameters") or - parsed.get("input") or - parsed.get("args") or - {} + parsed.get("arguments") + or parsed.get("parameters") + or parsed.get("input") + or parsed.get("args") + or {} ) elif "tool" in parsed: name = parsed["tool"] @@ -632,7 +915,6 @@ class ToolCallParser: tool_call: Dict, available_tools: List[Dict] ) -> Optional[Dict]: - """التحقق من أن الأداة موجودة في القائمة المتاحة""" requested_name = tool_call["name"] tool_names = [] @@ -641,7 +923,6 @@ class ToolCallParser: if not tool_name and "function" in tool: tool_name = tool["function"].get("name", "") tool_names.append(tool_name) - if tool_name == requested_name: return tool_call @@ -655,13 +936,15 @@ class ToolCallParser: tool_call["name"] = tool_name return tool_call - logger.warning(f"[Validator] Tool '{requested_name}' not found in: {tool_names}") + logger.warning(f"[Validator] Tool '{requested_name}' not in: {tool_names}") return tool_call @classmethod def _try_fix_json(cls, broken_json: str) -> Optional[Dict]: - """محاولة إصلاح JSON مكسور""" - fixed = re.sub(r',\s*([}\]])', r'\1', broken_json) + # تنظيف أولي + fixed = cls._clean_json_string(broken_json) + + # إغلاق الأقواس المفتوحة open_braces = fixed.count('{') - fixed.count('}') if open_braces > 0: fixed += '}' * open_braces @@ -674,6 +957,7 @@ class ToolCallParser: except json.JSONDecodeError: pass + # محاولة استخراج JSON من وسط النص try: start = fixed.index('{') depth = 0 @@ -689,15 +973,10 @@ class ToolCallParser: return None - # ===================================================== # STREAM TOOL BUFFER # ===================================================== class StreamToolBuffer: - """ - Buffer ذكي يجمع chunks الـ stream ويكتشف tool calls. - """ - def __init__(self, available_tools: Optional[List[Dict]] = None): self.buffer = "" self.in_tool_call = False @@ -710,10 +989,8 @@ class StreamToolBuffer: self._stream_started_at = time.time() def feed(self, chunk: str) -> List[Dict]: - """إطعام chunk جديد للـ buffer.""" events = [] self.buffer += chunk - while self.buffer: if self.in_tool_call: events.extend(self._process_tool_call_mode()) @@ -724,11 +1001,9 @@ class StreamToolBuffer: if self.in_tool_call: continue break - return events def _process_text_mode(self) -> List[Dict]: - """معالجة النص في النمط العادي.""" events = [] buffer_lower = self.buffer.lower() earliest_pos = -1 @@ -753,7 +1028,6 @@ class StreamToolBuffer: events.append({"type": "text", "text": text_before}) elif text_before and text_before.isspace(): events.append({"type": "text", "text": " "}) - self.buffer = self.buffer[earliest_pos:] self.in_tool_call = True self.tool_call_buffer = "" @@ -763,7 +1037,6 @@ class StreamToolBuffer: return events def _set_active_end_marker(self, start_marker: str): - """تحديد علامة النهاية المقابلة لعلامة البداية""" start_lower = start_marker.lower() if start_lower == '': self._active_end_marker = '' @@ -779,9 +1052,7 @@ class StreamToolBuffer: self._active_end_marker = '' def _process_tool_call_mode(self) -> List[Dict]: - """معالجة النص في نمط tool call.""" events = [] - end_markers_pairs = [ ('', ''), ('```', '```tool_call'), @@ -790,28 +1061,22 @@ class StreamToolBuffer: ('[/tool_call]', '[tool_call]'), ('[/TOOL_CALL]', '[TOOL_CALL]'), ] - buffer_lower = self.buffer.lower() found_end = False for end_marker, start_marker in end_markers_pairs: end_lower = end_marker.lower() start_lower = start_marker.lower() - if not buffer_lower.startswith(start_lower): continue - search_from = len(start_marker) end_pos = buffer_lower.find(end_lower, search_from) - if end_pos != -1: full_tool_text = self.buffer[:end_pos + len(end_marker)] remaining = self.buffer[end_pos + len(end_marker):] - clean_text, tool_calls = ToolCallParser.parse_tool_calls( full_tool_text, self.available_tools ) - for tc in tool_calls: if not tc.get("id"): tc["id"] = ToolCallParser.generate_tool_id() @@ -820,10 +1085,8 @@ class StreamToolBuffer: f"(id: {tc['id']})" ) events.append(tc) - if clean_text.strip(): events.append({"type": "text", "text": clean_text}) - self.buffer = remaining self.in_tool_call = False self._active_start_marker = "" @@ -834,8 +1097,7 @@ class StreamToolBuffer: if not found_end: if len(self.buffer) > 5000: logger.warning( - "[Buffer] Tool call buffer too large without closing tag, " - "treating as text" + "[Buffer] Tool call buffer too large, treating as text" ) events.append({"type": "text", "text": self.buffer}) self.buffer = "" @@ -846,26 +1108,20 @@ class StreamToolBuffer: return events def flush(self) -> List[Dict]: - """تفريغ أي محتوى متبقي في الـ buffer مع إصلاح الوسوم غير المكتملة.""" events = [] - if self.in_tool_call and self.buffer: logger.warning( - f"[Buffer] Stream ended with unclosed tool_call tag. " - f"Active marker: '{self._active_start_marker}'. " + f"[Buffer] Stream ended with unclosed tool_call. " f"Attempting manual closure..." ) - end_marker = self._active_end_marker or "" buffer_with_close = self.buffer + end_marker - clean_text, tool_calls = ToolCallParser.parse_tool_calls( buffer_with_close, self.available_tools ) - if tool_calls: logger.info( - f"[Buffer] Manual closure SUCCESS: recovered {len(tool_calls)} tool call(s)" + f"[Buffer] Manual closure SUCCESS: {len(tool_calls)} tool call(s)" ) for tc in tool_calls: if not tc.get("id"): @@ -874,14 +1130,12 @@ class StreamToolBuffer: if clean_text.strip(): events.append({"type": "text", "text": clean_text}) else: - # محاولة إصلاح JSON الناقص raw_buffer = self.buffer if self._active_start_marker: start_lower = self._active_start_marker.lower() raw_lower = raw_buffer.lower() if raw_lower.startswith(start_lower): raw_buffer = raw_buffer[len(self._active_start_marker):] - json_start = raw_buffer.find('{') if json_start != -1: json_fragment = raw_buffer[json_start:].strip() @@ -891,15 +1145,12 @@ class StreamToolBuffer: open_brackets = json_fragment.count('[') - json_fragment.count(']') if open_brackets > 0: json_fragment += ']' * open_brackets - repaired_text = ( self._active_start_marker + json_fragment + end_marker ) - clean_text2, tool_calls2 = ToolCallParser.parse_tool_calls( repaired_text, self.available_tools ) - if tool_calls2: for tc in tool_calls2: if not tc.get("id"): @@ -913,7 +1164,6 @@ class StreamToolBuffer: else: if self.buffer.strip(): events.append({"type": "text", "text": self.buffer}) - elif self.buffer: if self.buffer.strip(): events.append({"type": "text", "text": self.buffer}) @@ -922,39 +1172,40 @@ class StreamToolBuffer: self.in_tool_call = False self._active_start_marker = "" self._active_end_marker = "" - return events - # ===================================================== -# TOOLS FORMATTER - نسخة متوازنة (لا تشدد مفرط) +# TOOLS FORMATTER - محسّن # ===================================================== class ToolsFormatter: - """تحويل أدوات Anthropic إلى نص للنموذج - صيغة متوازنة""" - @staticmethod def format_tools_for_prompt(tools: List[Dict], tool_choice: Any = None) -> str: - """ - تنسيق الأدوات بصيغة واضحة دون ضغط مفرط. - الضغط المفرط كان سبب تجاهل النموذج للأوامر. - """ if not tools: return "" lines = [] - lines.append("# Available Tools") + lines.append("# Tools Available") lines.append("") - lines.append("You have access to the following tools.") - lines.append("When you need to use a tool, format your response like this:") + lines.append( + "**CRITICAL RULE**: You are an EXECUTOR, not a DESCRIBER. " + "When you decide to use a tool, you MUST call it immediately " + "using the format below. " + "NEVER write 'I will use...' or 'I'm going to...' - just DO IT." + ) lines.append("") + lines.append("## ✅ CORRECT - How to call a tool:") lines.append("") - lines.append('{"name": "tool_name", "arguments": {"param1": "value1"}}') + lines.append('{"name": "tool_name", "arguments": {"param": "value"}}') lines.append("") lines.append("") - lines.append("You can include text before or after the tool_call block.") - lines.append("You can make multiple tool calls using multiple blocks.") + lines.append("## ❌ WRONG - What NOT to do:") + lines.append(' "I will now use the write_file tool to..." → FORBIDDEN') + lines.append(' "Let me create the file..." → FORBIDDEN') + lines.append(' "I\'ll use the tool to..." → FORBIDDEN') + lines.append("") + lines.append("---") lines.append("") - lines.append("## Tool Definitions:") + lines.append("## Available Tools:") lines.append("") for i, tool in enumerate(tools, 1): @@ -963,54 +1214,71 @@ class ToolsFormatter: input_schema = tool.get("input_schema", {}) lines.append(f"### {i}. `{name}`") - lines.append(f"**Description:** {description}") + lines.append(f"**Purpose:** {description}") properties = input_schema.get("properties", {}) required = input_schema.get("required", []) + example_args = {} if properties: lines.append("**Parameters:**") for param_name, param_info in properties.items(): param_type = param_info.get("type", "any") param_desc = param_info.get("description", "") is_required = param_name in required - req_marker = " (required)" if is_required else " (optional)" + req_marker = " *(required)*" if is_required else " *(optional)*" lines.append( f" - `{param_name}` ({param_type}{req_marker}): {param_desc}" ) if "enum" in param_info: lines.append( - f" Allowed values: " - f"{', '.join(str(v) for v in param_info['enum'])}" + f" Values: {', '.join(str(v) for v in param_info['enum'])}" ) - else: - lines.append("**Parameters:** None") + if param_name in required: + if param_type == "string": + example_args[param_name] = f"<{param_name}_value>" + elif param_type == "integer": + example_args[param_name] = 0 + elif param_type == "boolean": + example_args[param_name] = True + elif param_type == "array": + example_args[param_name] = [] + else: + example_args[param_name] = f"<{param_name}>" + lines.append(f"**Call it like this:**") + lines.append("") + example = {"name": name, "arguments": example_args} + lines.append(json.dumps(example, ensure_ascii=False, indent=2)) + lines.append("") lines.append("") - # معالجة tool_choice بشكل معتدل + # معالجة tool_choice if tool_choice: if isinstance(tool_choice, dict): if tool_choice.get("type") == "tool": forced_tool = tool_choice.get("name", "") if forced_tool: + lines.append("---") lines.append( - f"**Note:** Please use the `{forced_tool}` tool for this request." + f"**⚡ MANDATORY:** You MUST call `{forced_tool}` right now. " + f"Use the format above." ) elif tool_choice.get("type") == "any": + lines.append("---") lines.append( - "**Note:** Please use at least one tool to complete this request." + "**⚡ MANDATORY:** You MUST call at least one tool. " + "Use the format above." ) elif tool_choice == "any": - lines.append("**Note:** Please use at least one tool.") + lines.append("---") + lines.append("**⚡ MANDATORY:** Use at least one tool now.") lines.append("") - return "\n".join(lines) @staticmethod def format_tool_result_for_message(tool_use_id: str, content: Any) -> str: - """تحويل نتيجة أداة إلى نص مفهوم للنموذج.""" result_text = "" if isinstance(content, str): result_text = content @@ -1038,18 +1306,10 @@ class ToolsFormatter: f"[End Tool Result]" ) - # ===================================================== -# MESSAGE CONVERTER - محسّن للحفاظ على السياق الكامل +# MESSAGE CONVERTER # ===================================================== class MessageConverter: - """ - تحويل رسائل Anthropic إلى صيغة g4f مع الحفاظ الكامل على السياق. - - الإصلاح الجوهري: لا نحذف أي رسائل من التاريخ إلا عند الضرورة القصوى، - والسياق يُحفظ في PersistentContextStore ويُحقن في كل طلب. - """ - @staticmethod def convert_messages( messages: List[Dict], @@ -1058,13 +1318,10 @@ class MessageConverter: tool_choice: Any = None, session_id: str = "" ) -> Tuple[str, List[Dict]]: - """تحويل رسائل Anthropic إلى (full_message, history) لـ g4f""" history = [] - - # بناء system prompt full_system = system_prompt if system_prompt else "" - # إضافة تعريف الأدوات إذا وجدت + # إضافة تعريف الأدوات if tools: tools_text = ToolsFormatter.format_tools_for_prompt(tools, tool_choice) if full_system: @@ -1072,7 +1329,7 @@ class MessageConverter: else: full_system = tools_text - # حقن سياق المهمة المحفوظ (منع النسيان عند تبديل النماذج) + # حقن سياق المهمة مع التوجيه التنفيذي if session_id: context_summary = CONTEXT_STORE.get_context_summary(session_id) if context_summary: @@ -1081,10 +1338,10 @@ class MessageConverter: else: full_system = context_summary logger.info( - f"[Context] Injected persistent context for session {session_id[:8]}..." + f"[Context] Injected context+directive for session {session_id[:8]}..." ) - # معالجة الرسائل وبناء التاريخ + # معالجة الرسائل for msg in messages: role = msg.get("role", "user") content = msg.get("content", "") @@ -1098,7 +1355,6 @@ class MessageConverter: continue converted_text = MessageConverter._convert_content(content, role) - if converted_text: g4f_role = "user" if role == "user" else "assistant" history.append({"role": g4f_role, "content": converted_text}) @@ -1110,7 +1366,7 @@ class MessageConverter: else: user_message = "" - # بناء الرسالة الكاملة مع system prompt + # بناء الرسالة الكاملة if full_system: full_message = ( f"[System Instructions]\n{full_system}\n" @@ -1123,7 +1379,6 @@ class MessageConverter: @staticmethod def _convert_content(content: Any, role: str) -> str: - """تحويل محتوى رسالة واحدة مع الحفاظ على كل المعلومات""" if isinstance(content, str): return content @@ -1134,10 +1389,8 @@ class MessageConverter: parts.append(block) elif isinstance(block, dict): block_type = block.get("type", "") - if block_type == "text": parts.append(block.get("text", "")) - elif block_type == "tool_use": name = block.get("name", "unknown") input_data = block.get("input", {}) @@ -1149,53 +1402,36 @@ class MessageConverter: f'"arguments": {json.dumps(input_data, ensure_ascii=False)}}}' f"" ) - elif block_type == "tool_result": tool_use_id = block.get("tool_use_id", "") result_content = block.get("content", "") is_error = block.get("is_error", False) - result_text = ToolsFormatter.format_tool_result_for_message( tool_use_id, result_content ) if is_error: result_text = f"[ERROR] {result_text}" parts.append(result_text) - elif block_type == "image": parts.append("[Image content - not supported in text mode]") - else: if "text" in block: parts.append(block["text"]) else: parts.append(json.dumps(block, ensure_ascii=False)) - return "\n".join(parts) if content is not None: return str(content) return "" - # ===================================================== # SMART HISTORY MANAGER -# يدير التاريخ بذكاء للحفاظ على السياق الكامل # ===================================================== class SmartHistoryManager: - """ - مدير التاريخ الذكي - يحل مشكلة النسيان بشكل نهائي. - - الاستراتيجية: - 1. يحتفظ دائماً بأول N رسالة (تحتوي المهمة الأصلية) - 2. يحتفظ دائماً بآخر M رسالة (السياق الحالي) - 3. يضغط الرسائل الوسطى بدلاً من حذفها - 4. يستخرج ويحفظ نتائج الأدوات في PersistentContextStore - """ - - FIRST_MESSAGES_TO_KEEP = 5 # أول 5 رسائل (المهمة الأصلية) - LAST_MESSAGES_TO_KEEP = 20 # آخر 20 رسالة (السياق الحالي) - MAX_TOTAL_MESSAGES = 30 # الحد الأقصى الإجمالي + FIRST_MESSAGES_TO_KEEP = 5 + LAST_MESSAGES_TO_KEEP = 20 + MAX_TOTAL_MESSAGES = 30 @classmethod def process_history( @@ -1203,35 +1439,30 @@ class SmartHistoryManager: history: List[Dict], session_id: str = "" ) -> List[Dict]: - """ - معالجة التاريخ بذكاء مع استخراج وحفظ المعلومات المهمة. - """ if not history: return [] - # استخراج نتائج الأدوات وحفظها في المخزن الدائم if session_id: cls._extract_and_store_tool_results(history, session_id) total = len(history) - - # إذا كان التاريخ صغيراً، نحتفظ بكله if total <= cls.MAX_TOTAL_MESSAGES: - logger.info(f"[History] Full history: {total} messages (under limit)") + logger.info(f"[History] Full history: {total} messages") return history - # التاريخ كبير: نطبق الاستراتيجية الذكية first_part = history[:cls.FIRST_MESSAGES_TO_KEEP] last_part = history[-cls.LAST_MESSAGES_TO_KEEP:] - - # إنشاء ملخص للجزء الوسط middle_part = history[cls.FIRST_MESSAGES_TO_KEEP:-cls.LAST_MESSAGES_TO_KEEP] + if middle_part: summary = cls._summarize_middle(middle_part) if summary: summary_msg = { "role": "assistant", - "content": f"[Context Summary - {len(middle_part)} messages compressed]\n{summary}" + "content": ( + f"[Context Summary - {len(middle_part)} messages compressed]\n" + f"{summary}" + ) } smart_history = first_part + [summary_msg] + last_part else: @@ -1240,10 +1471,8 @@ class SmartHistoryManager: smart_history = first_part + last_part logger.info( - f"[History] Smart compression: {total} → {len(smart_history)} messages " - f"(kept first {cls.FIRST_MESSAGES_TO_KEEP} + last {cls.LAST_MESSAGES_TO_KEEP})" + f"[History] Smart compression: {total} → {len(smart_history)} messages" ) - return smart_history @classmethod @@ -1252,13 +1481,10 @@ class SmartHistoryManager: history: List[Dict], session_id: str ): - """استخراج نتائج الأدوات من التاريخ وحفظها في المخزن الدائم""" for msg in history: content = msg.get("content", "") if isinstance(content, str): - # البحث عن نتائج الأدوات في النص - if "[Tool Result" in content or "[SYSTEM]: Research results" in content: - # استخراج اسم الأداة والنتيجة + if "[Tool Result" in content: tool_match = re.search( r'\[Tool Result - ID: ([^\]]+)\]\n(.*?)\n\[End Tool Result\]', content, @@ -1266,28 +1492,208 @@ class SmartHistoryManager: ) if tool_match: tool_id = tool_match.group(1) - result = tool_match.group(2)[:500] # أول 500 حرف + result = tool_match.group(2)[:500] CONTEXT_STORE.add_tool_result( session_id, "tool", tool_id, result ) @classmethod def _summarize_middle(cls, messages: List[Dict]) -> str: - """إنشاء ملخص نصي للرسائل الوسطى""" if not messages: return "" - summary_parts = [] for msg in messages: role = msg.get("role", "") - content = str(msg.get("content", ""))[:200] # أول 200 حرف + content = str(msg.get("content", ""))[:200] summary_parts.append(f"[{role}]: {content}...") + return "\n".join(summary_parts[:10]) - return "\n".join(summary_parts[:10]) # أول 10 رسائل من الملخص +# ===================================================== +# EXECUTION ENFORCER - القلب الحقيقي للحل +# ===================================================== +class ExecutionEnforcer: + """ + المحرك التنفيذي الإجباري. + يحل المشاكل الأربع: + 1. "المشاهد الذكي" → يكتشف الوصف ويُجبر على التنفيذ + 2. "ضياع الأوامر" → يُعيد الطلب بتنسيق صحيح ومضمون + 3. "غياب الدفعة الأخيرة" → يُرسل رسالة إجبارية للتنفيذ + 4. "تشتت الهوية" → يُعرّف النموذج كـ EXECUTOR لا DESCRIBER + """ + + MAX_EXECUTION_RETRIES = 2 + + @classmethod + def build_forced_execution_message( + cls, + tool_name: str, + tool_args: Dict, + reason: str = "" + ) -> str: + """ + يبني رسالة تُجبر النموذج على تنفيذ أداة محددة. + هذا هو "الدفعة الأخيرة" المفقودة. + """ + tool_call_json = json.dumps( + {"name": tool_name, "arguments": tool_args}, + ensure_ascii=False, + indent=2 + ) + + msg_parts = [ + "EXECUTE THIS TOOL CALL NOW.", + "", + ] + + if reason: + msg_parts.append(f"Reason: {reason}") + msg_parts.append("") + + msg_parts.extend([ + "Copy this EXACTLY:", + "", + "", + tool_call_json, + "", + "", + "No other text needed. Just the tool_call block." + ]) + + return "\n".join(msg_parts) + + @classmethod + def pre_process_request( + cls, + message: str, + session_id: str, + tools: List[Dict] + ) -> str: + """ + معالجة الطلب قبل إرساله. + يضيف توجيهات إجبارية إذا كانت هناك أداة معلّقة. + """ + if not session_id or not tools: + return message + + ctx = CONTEXT_STORE.get_or_create_session(session_id) + state = ctx.get("current_state", ExecutionState.IDLE) + pending = ctx.get("pending_tool_calls", []) + desc_count = ctx.get("consecutive_description_count", 0) + + # إذا كانت هناك أداة معلّقة + if pending and state == ExecutionState.TOOL_PENDING: + pending_tool = pending[0] + tool_name = pending_tool.get("name", "unknown") + tool_args = pending_tool.get("arguments", {}) + + reminder = ( + f"\n\n[EXECUTION REMINDER]: " + f"You must call `{tool_name}` using:\n" + f"\n" + f"{json.dumps({'name': tool_name, 'arguments': tool_args}, ensure_ascii=False, indent=2)}\n" + f"" + ) + return message + reminder + + # إذا وصف بدون تنفيذ مرات متعددة + if desc_count >= 2: + warning = ( + f"\n\n[SYSTEM WARNING - EXECUTION REQUIRED]: " + f"You have described actions {desc_count} times without executing them. " + f"Your response MUST contain a block. " + f"Descriptions are not acceptable." + ) + return message + warning + + return message + + @classmethod + def post_process_response( + cls, + response: str, + session_id: str, + tools: List[Dict], + tool_calls_found: List[Dict] + ) -> Dict: + """ + تحليل الرد بعد استلامه. + يقرر هل يحتاج "دفعة" أخرى. + """ + if not session_id: + return {"needs_retry": False, "retry_message": "", "analysis": {}} + + analysis = ResponseAnalyzer.analyze(response, tools) + + # إذا وجد tool calls فعلية - نجاح + if tool_calls_found: + for tc in tool_calls_found: + CONTEXT_STORE.add_completed_step( + session_id, + f"Executed tool: {tc.get('name', 'unknown')} " + f"with args: {json.dumps(tc.get('input', {}))[:100]}" + ) + # إعادة ضبط عداد الوصف + CONTEXT_STORE.update_session( + session_id, + described_but_not_executed=[], + consecutive_description_count=0, + current_state=ExecutionState.TOOL_EXECUTING + ) + return {"needs_retry": False, "retry_message": "", "analysis": analysis} + + # إذا وصف بدون تنفيذ + if analysis["has_description_without_execution"]: + desc_count = CONTEXT_STORE.increment_description_count(session_id, response) + + if desc_count <= cls.MAX_EXECUTION_RETRIES: + intended_tool = analysis.get("intended_tool_name") + + if intended_tool: + # بناء رسالة إجبارية للأداة المقصودة + tool_def = next( + (t for t in tools if t.get("name") == intended_tool), + None + ) + example_args = {} + if tool_def: + schema = tool_def.get("input_schema", {}) + for param_name in schema.get("required", []): + param_info = schema.get("properties", {}).get(param_name, {}) + param_type = param_info.get("type", "string") + if param_type == "string": + example_args[param_name] = f"<{param_name}>" + elif param_type == "integer": + example_args[param_name] = 0 + elif param_type == "boolean": + example_args[param_name] = True + else: + example_args[param_name] = None + + retry_message = cls.build_forced_execution_message( + intended_tool, + example_args, + f"You mentioned using {intended_tool} but didn't call it" + ) + else: + retry_message = ( + "You described an action without executing it. " + "Use a block now:\n\n" + "\n" + '{"name": "TOOL_NAME", "arguments": {ARGUMENTS}}\n' + "" + ) + + return { + "needs_retry": True, + "retry_message": retry_message, + "analysis": analysis + } + + return {"needs_retry": False, "retry_message": "", "analysis": analysis} # ===================================================== -# CHAT LOGIC - الإصلاح الجوهري +# CHAT LOGIC - مع محرك التنفيذ الكامل # ===================================================== def ask( message: str, @@ -1299,21 +1705,18 @@ def ask( session_id: str = "" ): """ - دالة الدردشة الرئيسية - مُصلحة بشكل جوهري. - - الإصلاحات: - 1. لا STRICT MODE مفرط - فقط تعليمات واضحة ومعقولة - 2. حفظ السياق في PersistentContextStore - 3. SmartHistoryManager يدير التاريخ بذكاء - 4. Fallback يبدأ من المزود المطلوب فعلاً - 5. لا حقن هدف مفرط يربك النموذج + دالة الدردشة الرئيسية مع محرك التنفيذ الإجباري. """ message = (message or "").strip() if not message: yield "" return - # لا نستخدم الكاش عند وجود أدوات لأن الأدوات تتطلب استجابة جديدة دائماً + # تطبيق المعالجة المسبقة للتنفيذ + if tools and session_id: + message = ExecutionEnforcer.pre_process_request(message, session_id, tools) + + # لا كاش عند وجود أدوات if not tools: key = f"{provider_name}|{model_name}|{message[:200]}" cached = CACHE.get(key) @@ -1323,13 +1726,12 @@ def ask( else: key = None - # معالجة التاريخ بذكاء + # معالجة التاريخ msgs = [] try: if history: if isinstance(history, list) and len(history) > 0: if isinstance(history[0], dict): - # استخدام SmartHistoryManager smart_history = SmartHistoryManager.process_history( history, session_id ) @@ -1345,12 +1747,10 @@ def ask( if text: msgs.append({"role": str(role), "content": text}) else: - # تنسيق tuples القديم if len(history) > 30: smart_history_tuples = history[:5] + history[-25:] else: smart_history_tuples = history - for item in smart_history_tuples: if isinstance(item, (list, tuple)) and len(item) == 2: if item[0]: @@ -1358,27 +1758,23 @@ def ask( if item[1]: msgs.append({"role": "assistant", "content": str(item[1])}) except Exception as e: - logger.warning(f"[History] Error processing history: {e}") + logger.warning(f"[History] Error: {e}") - # إضافة الرسالة الحالية msgs.append({"role": "user", "content": message}) # تحديث المخزن الدائم if session_id: ctx = CONTEXT_STORE.get_or_create_session(session_id) - # حفظ المهمة الأصلية إذا لم تُحفظ بعد if not ctx.get("original_task") and len(msgs) <= 2: CONTEXT_STORE.update_session( session_id, original_task=message[:500], - current_state="processing" + current_state=ExecutionState.PLANNING ) ctx["message_count"] = ctx.get("message_count", 0) + 1 - # قائمة المزودين للـ fallback - يبدأ من المزود المطلوب + # قائمة المزودين all_provider_names = list(REAL_PROVIDERS.keys()) - - # ترتيب المزودين: المطلوب أولاً، ثم الباقين if provider_name in all_provider_names: fallback_providers = [provider_name] + [ p for p in all_provider_names if p != provider_name @@ -1387,22 +1783,17 @@ def ask( fallback_providers = all_provider_names used = [] - for pname in fallback_providers: if pname in used: continue used.append(pname) pobj = REAL_PROVIDERS.get(pname) if not pobj: - logger.info(f"[Fallback] Provider {pname} not available, skipping") continue - models_list = discover_provider_models(pobj, pname) if not models_list: - logger.warning(f"[Fallback] No models for provider {pname}") continue - # ترتيب النماذج: المطلوب أولاً if model_name in models_list: model_candidates = [model_name] + [m for m in models_list if m != model_name] else: @@ -1410,20 +1801,17 @@ def ask( for m in model_candidates[:5]: try: - logger.info(f"[Fallback] Trying provider {pname} with model {m}") + logger.info(f"[Fallback] Trying {pname}/{m}") - # تسجيل المزود المستخدم في المخزن الدائم if session_id: - CONTEXT_STORE.get_or_create_session(session_id) - provider_history = CONTEXT_STORE._store[session_id].get( - "provider_history", [] - ) + provider_history = CONTEXT_STORE._store.get( + session_id, {} + ).get("provider_history", []) provider_history.append({ "provider": pname, "model": m, "timestamp": time.time() }) - # الاحتفاظ بآخر 20 مزود فقط CONTEXT_STORE._store[session_id]["provider_history"] = ( provider_history[-20:] ) @@ -1435,6 +1823,7 @@ def ask( stream=True, timeout=60 ) + buffer = [] got_response = False for chunk in stream: @@ -1449,80 +1838,64 @@ def ask( full = "".join(buffer) if full.strip() and got_response: - # حفظ في الكاش فقط إذا لم تكن هناك أدوات if key: CACHE.set(key, full) - # تحديث حالة الجلسة if session_id: CONTEXT_STORE.update_session( session_id, - current_state="completed_step" + last_model_response=full[:500], + current_state=ExecutionState.AWAITING_NEXT ) return except Exception as e: logger.warning( - f"[Fallback] Provider {pname} model {m} failed: {str(e)[:200]}" + f"[Fallback] {pname}/{m} failed: {str(e)[:200]}" ) continue yield "❌ فشلت جميع المزودات. تأكد من اتصال الإنترنت أو حاول لاحقاً." - # ===================================================== # SESSION ID EXTRACTOR -# استخراج معرف الجلسة من الطلب # ===================================================== def extract_session_id(request: Request, body: Dict) -> str: - """ - استخراج أو توليد معرف جلسة فريد. - يستخدم لربط الطلبات المتعددة بنفس المحادثة. - """ - # محاولة استخراج من الـ headers session_id = ( - request.headers.get("X-Session-ID", "") or - request.headers.get("x-session-id", "") or - request.headers.get("X-Conversation-ID", "") or - "" + request.headers.get("X-Session-ID", "") + or request.headers.get("x-session-id", "") + or request.headers.get("X-Conversation-ID", "") + or "" ) - if session_id: return session_id - # محاولة استخراج من الـ metadata metadata = body.get("metadata", {}) if isinstance(metadata, dict): session_id = ( - metadata.get("session_id", "") or - metadata.get("conversation_id", "") or - metadata.get("user_id", "") or - "" + metadata.get("session_id", "") + or metadata.get("conversation_id", "") + or metadata.get("user_id", "") + or "" ) - if session_id: return session_id - # توليد معرف من محتوى الرسائل (ثابت لنفس المحادثة) messages = body.get("messages", []) if messages: - # استخدام أول رسالة لتوليد معرف ثابت first_msg = messages[0] content = str(first_msg.get("content", ""))[:100] - # معرف شبه ثابت بناءً على المحتوى import hashlib hash_val = hashlib.md5(content.encode()).hexdigest()[:16] return f"auto_{hash_val}" - # توليد معرف عشوائي كحل أخير return f"rand_{uuid.uuid4().hex[:16]}" - # ===================================================== # FASTAPI # ===================================================== app = FastAPI( - title="G4F Smart Router", - description="AI Gateway - Protocol Translator for Anthropic (Fixed Version)" + title="G4F Execution Engine", + description="AI Gateway with Execution Enforcement" ) app.add_middleware( @@ -1535,7 +1908,6 @@ app.add_middleware( API_KEY = os.getenv("API_KEY", "mysecretkey123") - class ChatRequest(BaseModel): message: str provider: str = "Blackbox" @@ -1543,10 +1915,6 @@ class ChatRequest(BaseModel): history: List[Any] = [] session_id: str = "" - -# ===================================================== -# التحقق من المفتاح -# ===================================================== def verify_api_key(request: Request): auth = request.headers.get("Authorization", "").strip() x_key = request.headers.get("X-API-Key", "").strip() @@ -1566,7 +1934,6 @@ def verify_api_key(request: Request): detail="Invalid API key. Use 'Authorization: Bearer KEY' or 'X-API-Key: KEY'" ) - # ===================================================== # دعم HEAD # ===================================================== @@ -1590,9 +1957,8 @@ async def head_messages(): async def head_chat_completions(): return Response(status_code=200) - # ===================================================== -# نقاط نهاية متوافقة مع Anthropic API +# نقاط نهاية Anthropic API # ===================================================== @app.get("/v1/models") async def v1_models(request: Request): @@ -1622,16 +1988,10 @@ async def v1_models(request: Request): }] return {"object": "list", "data": models} - -# ===================================================== -# نقطة /v1/messages - بروتوكول Anthropic الكامل -# ===================================================== @app.post("/v1/messages") async def v1_messages(request: Request): verify_api_key(request) body = await request.json() - - # استخراج معرف الجلسة session_id = extract_session_id(request, body) logger.info( @@ -1647,7 +2007,6 @@ async def v1_messages(request: Request): model = body.get("model", "gpt-4o") system_prompt = body.get("system", "") - if isinstance(system_prompt, list): sys_parts = [] for sp in system_prompt: @@ -1663,21 +2022,14 @@ async def v1_messages(request: Request): tool_choice = body.get("tool_choice", "auto") metadata = body.get("metadata", {}) - # استخراج المهمة الأصلية وحفظها + # حفظ المهمة الأصلية ctx = CONTEXT_STORE.get_or_create_session(session_id) if not ctx.get("original_task"): original_task = CONTEXT_STORE.extract_task_from_messages(messages) if original_task: - CONTEXT_STORE.update_session( - session_id, - original_task=original_task - ) - logger.info( - f"[Context] Stored original task for session {session_id[:8]}: " - f"{original_task[:50]}..." - ) + CONTEXT_STORE.update_session(session_id, original_task=original_task) - # تحويل الرسائل مع حقن السياق + # تحويل الرسائل full_message, history = MessageConverter.convert_messages( messages, system_prompt, tools, tool_choice, session_id ) @@ -1690,11 +2042,11 @@ async def v1_messages(request: Request): if is_stream: return await _handle_anthropic_stream( - full_message, history, model, max_tokens, tools, tool_choice, - metadata, session_id + full_message, history, model, max_tokens, + tools, tool_choice, metadata, session_id ) - # Non-stream: تجميع الرد كاملاً + # Non-stream full_response = "" for chunk in ask( full_message, history, "Blackbox", model, @@ -1704,17 +2056,40 @@ async def v1_messages(request: Request): clean_text, tool_calls = ToolCallParser.parse_tool_calls(full_response, tools) + # تطبيق محرك التنفيذ + if tools and session_id: + enforcement = ExecutionEnforcer.post_process_response( + full_response, session_id, tools, tool_calls + ) + if enforcement["needs_retry"]: + logger.info( + f"[Enforcer] Retry needed: {enforcement['retry_message'][:100]}" + ) + retry_response = "" + for chunk in ask( + enforcement["retry_message"], [], "Blackbox", model, + tools=tools, session_id=session_id + ): + retry_response += chunk + + if retry_response: + clean_text2, tool_calls2 = ToolCallParser.parse_tool_calls( + retry_response, tools + ) + if tool_calls2: + tool_calls = tool_calls2 + clean_text = clean_text2 + logger.info( + f"[Enforcer] Retry SUCCESS: {len(tool_calls2)} tool calls" + ) + message_id = f"msg_{int(time.time())}_{os.urandom(4).hex()}" input_tokens = max(1, len(full_message) // 4) output_tokens = max(1, len(full_response) // 4) content_blocks = [] - if clean_text.strip(): - content_blocks.append({ - "type": "text", - "text": clean_text.strip() - }) + content_blocks.append({"type": "text", "text": clean_text.strip()}) for tc in tool_calls: tool_id = tc.get("id") or ToolCallParser.generate_tool_id() @@ -1724,10 +2099,7 @@ async def v1_messages(request: Request): "name": tc["name"], "input": tc.get("input", {}) }) - logger.info( - f"[API] Tool call in response: {tc['name']} (id: {tool_id})" - ) - # حفظ خطوة الأداة في المخزن الدائم + logger.info(f"[API] Tool call: {tc['name']} (id: {tool_id})") if session_id: CONTEXT_STORE.add_completed_step( session_id, @@ -1735,10 +2107,7 @@ async def v1_messages(request: Request): ) if not content_blocks: - content_blocks.append({ - "type": "text", - "text": full_response or "" - }) + content_blocks.append({"type": "text", "text": full_response or ""}) stop_reason = "tool_use" if tool_calls else "end_turn" @@ -1756,9 +2125,8 @@ async def v1_messages(request: Request): } } - # ===================================================== -# معالج الـ streaming بصيغة Anthropic SSE +# معالج الـ streaming # ===================================================== async def _handle_anthropic_stream( full_message: str, @@ -1796,12 +2164,12 @@ async def _handle_anthropic_stream( ) tool_buffer = StreamToolBuffer(available_tools=tools_list) - current_block_index = 0 text_block_open = False output_tokens = 0 has_tool_calls = False detected_tool_calls = [] + full_response_buffer = [] def make_text_block_start(index: int) -> str: block_start = { @@ -1826,25 +2194,19 @@ async def _handle_anthropic_stream( ) def make_block_stop(index: int) -> str: - block_stop = { - "type": "content_block_stop", - "index": index - } + block_stop = {"type": "content_block_stop", "index": index} return ( f"event: content_block_stop\n" f"data: {json.dumps(block_stop, ensure_ascii=False)}\n\n" ) def make_tool_use_events(index: int, tool_call: Dict) -> str: - """إنشاء أحداث tool_use للـ stream.""" events_str = "" tool_id = tool_call.get("id") or ToolCallParser.generate_tool_id() tool_name = tool_call.get("name", "unknown") tool_input = tool_call.get("input", {}) - logger.info( - f"[Stream] Sending tool_use: {tool_name} (id: {tool_id})" - ) + logger.info(f"[Stream] Sending tool_use: {tool_name} (id: {tool_id})") block_start = { "type": "content_block_start", @@ -1881,7 +2243,7 @@ async def _handle_anthropic_stream( events_str += make_block_stop(index) return events_str - # ===== معالجة الـ Stream ===== + # معالجة الـ Stream try: for chunk in ask( full_message, history, "Blackbox", model, @@ -1890,19 +2252,17 @@ async def _handle_anthropic_stream( if not chunk: continue + full_response_buffer.append(chunk) output_tokens += max(1, len(chunk) // 4) if not tools_list: - # بدون أدوات: إرسال مباشر if not text_block_open: yield make_text_block_start(current_block_index) text_block_open = True yield make_text_delta(current_block_index, chunk) continue - # مع أدوات: معالجة عبر الـ buffer events = tool_buffer.feed(chunk) - for event in events: if event.get("type") == "text": text = event["text"] @@ -1911,22 +2271,19 @@ async def _handle_anthropic_stream( yield make_text_block_start(current_block_index) text_block_open = True yield make_text_delta(current_block_index, text) - elif event.get("type") == "tool_use": if text_block_open: yield make_block_stop(current_block_index) current_block_index += 1 text_block_open = False - has_tool_calls = True detected_tool_calls.append(event) yield make_tool_use_events(current_block_index, event) current_block_index += 1 - # ===== تفريغ الـ buffer المتبقي ===== - logger.info("[Stream] Flushing remaining buffer...") + # تفريغ الـ buffer + logger.info("[Stream] Flushing buffer...") remaining_events = tool_buffer.flush() - for event in remaining_events: if event.get("type") == "text": text = event["text"] @@ -1935,22 +2292,75 @@ async def _handle_anthropic_stream( yield make_text_block_start(current_block_index) text_block_open = True yield make_text_delta(current_block_index, text) - elif event.get("type") == "tool_use": if text_block_open: yield make_block_stop(current_block_index) current_block_index += 1 text_block_open = False - has_tool_calls = True detected_tool_calls.append(event) logger.info( - f"[Stream] Recovered tool call from flush: " - f"{event.get('name')}" + f"[Stream] Recovered tool call: {event.get('name')}" ) yield make_tool_use_events(current_block_index, event) current_block_index += 1 + # ===== تطبيق محرك التنفيذ على الـ stream ===== + if tools_list and session_id and not has_tool_calls: + full_response = "".join(full_response_buffer) + enforcement = ExecutionEnforcer.post_process_response( + full_response, session_id, tools_list, detected_tool_calls + ) + + if enforcement["needs_retry"]: + logger.info( + f"[Stream Enforcer] Sending execution push..." + ) + retry_tool_buffer = StreamToolBuffer(available_tools=tools_list) + + for retry_chunk in ask( + enforcement["retry_message"], [], "Blackbox", model, + tools=tools_list, session_id=session_id + ): + if not retry_chunk: + continue + + retry_events = retry_tool_buffer.feed(retry_chunk) + for event in retry_events: + if event.get("type") == "text": + text = event["text"] + if text: + if not text_block_open: + yield make_text_block_start(current_block_index) + text_block_open = True + yield make_text_delta(current_block_index, text) + elif event.get("type") == "tool_use": + if text_block_open: + yield make_block_stop(current_block_index) + current_block_index += 1 + text_block_open = False + has_tool_calls = True + detected_tool_calls.append(event) + logger.info( + f"[Stream Enforcer] Tool call in retry: " + f"{event.get('name')}" + ) + yield make_tool_use_events(current_block_index, event) + current_block_index += 1 + + # تفريغ retry buffer + retry_remaining = retry_tool_buffer.flush() + for event in retry_remaining: + if event.get("type") == "tool_use": + if text_block_open: + yield make_block_stop(current_block_index) + current_block_index += 1 + text_block_open = False + has_tool_calls = True + detected_tool_calls.append(event) + yield make_tool_use_events(current_block_index, event) + current_block_index += 1 + except Exception as e: logger.error(f"[Stream] Error: {str(e)}") if not text_block_open: @@ -1966,7 +2376,7 @@ async def _handle_anthropic_stream( yield make_text_delta(0, "") yield make_block_stop(0) - # حفظ الأدوات المكتشفة في المخزن الدائم + # حفظ الأدوات المكتشفة if session_id and detected_tool_calls: for tc in detected_tool_calls: CONTEXT_STORE.add_completed_step( @@ -1976,7 +2386,6 @@ async def _handle_anthropic_stream( ) stop_reason = "tool_use" if has_tool_calls else "end_turn" - msg_delta = { "type": "message_delta", "delta": { @@ -1989,7 +2398,6 @@ async def _handle_anthropic_stream( f"event: message_delta\n" f"data: {json.dumps(msg_delta, ensure_ascii=False)}\n\n" ) - yield 'event: message_stop\ndata: {"type": "message_stop"}\n\n' return StreamingResponse( @@ -2002,7 +2410,6 @@ async def _handle_anthropic_stream( } ) - # ===================================================== # نقطة /v1/messages/stream # ===================================================== @@ -2010,7 +2417,6 @@ async def _handle_anthropic_stream( async def v1_messages_stream(request: Request): verify_api_key(request) body = await request.json() - session_id = extract_session_id(request, body) messages = body.get("messages", []) @@ -2037,19 +2443,17 @@ async def v1_messages_stream(request: Request): ) return await _handle_anthropic_stream( - full_message, history, model, max_tokens, tools, tool_choice, - {}, session_id + full_message, history, model, max_tokens, + tools, tool_choice, {}, session_id ) - # ===================================================== -# نقطة /v1/chat/completions (صيغة OpenAI) +# نقطة /v1/chat/completions (OpenAI) # ===================================================== @app.post("/v1/chat/completions") async def v1_chat_completions(request: Request): verify_api_key(request) body = await request.json() - session_id = extract_session_id(request, body) messages = body.get("messages", []) @@ -2069,15 +2473,14 @@ async def v1_chat_completions(request: Request): if role and content: history.append({"role": role, "content": content}) - # تطبيق SmartHistoryManager history = SmartHistoryManager.process_history(history, session_id) - completion_id = f"chatcmpl-{int(time.time())}_{os.urandom(4).hex()}" if is_stream: async def openai_stream(): for chunk in ask( - user_message, history, "Blackbox", model, session_id=session_id + user_message, history, "Blackbox", model, + session_id=session_id ): if chunk: data = { @@ -2119,7 +2522,8 @@ async def v1_chat_completions(request: Request): full_response = "" for chunk in ask( - user_message, history, "Blackbox", model, session_id=session_id + user_message, history, "Blackbox", model, + session_id=session_id ): full_response += chunk @@ -2143,98 +2547,80 @@ async def v1_chat_completions(request: Request): } } - # ===================================================== # نقاط نهاية إضافية # ===================================================== @app.get("/") async def root(): return { - "message": "G4F Smart Router - Fixed Version", - "version": "4.0.0", - "fixes_applied": [ - "Removed STRICT MODE (was causing model to ignore all commands)", - "Added PersistentContextStore (prevents forgetting between model switches)", - "Added SmartHistoryManager (intelligent history compression)", - "Fixed Fallback order (starts from requested provider, not always Blackbox)", - "Balanced tool instructions (clear but not aggressive)", - "Added session_id support for context persistence", - "Removed excessive goal injection (was confusing the model)", - "Stream tag auto-close preserved from v3" - ], - "providers": list(REAL_PROVIDERS.keys()), - "endpoints": { - "GET /": "Home", - "GET /health": "Health check", - "GET /v1/models": "List models", - "POST /v1/messages": "Anthropic format (AUTH)", - "POST /v1/messages/stream": "Anthropic stream (AUTH)", - "POST /v1/chat/completions": "OpenAI format (AUTH)", - "POST /chat": "Simple chat (AUTH)", - "POST /chat/stream": "Simple stream (AUTH)", - "GET /providers": "Providers list (AUTH)", - "GET /context/{session_id}": "View session context (AUTH)", - "DELETE /context/{session_id}": "Clear session context (AUTH)", + "message": "G4F Execution Engine", + "version": "6.0.0", + "problems_solved": { + "1_smart_watcher_syndrome": ( + "ResponseAnalyzer detects 'I will...' patterns " + "and flags them for immediate retry" + ), + "2_lost_in_translation": ( + "ToolCallParser._clean_json_string() fixes common JSON errors " + "before parsing - tolerant of extra spaces and characters" + ), + "3_missing_final_push": ( + "ExecutionEnforcer.build_forced_execution_message() " + "sends a mandatory execution message when description detected" + ), + "4_identity_confusion": ( + "ToolsFormatter defines the model as EXECUTOR not DESCRIBER " + "with clear forbidden patterns list" + ), }, + "providers": list(REAL_PROVIDERS.keys()), "cookies": COOKIE_STATUS, - "status": "✅ Server is working" + "status": "✅ Execution Engine v6 Active" } - @app.get("/health") async def health(): return { "status": "ok", - "version": "4.0.0", + "version": "6.0.0", "cookies": COOKIE_STATUS, "providers": list(REAL_PROVIDERS.keys()), "provider_count": len(REAL_PROVIDERS), "context_store": { "active_sessions": len(CONTEXT_STORE._store), }, - "fixes": { - "persistent_context_store": True, - "smart_history_manager": True, - "balanced_tool_instructions": True, - "correct_fallback_order": True, - "stream_tag_auto_close": True, - "removed_strict_mode": True, + "execution_engine": { + "response_analyzer": True, + "execution_enforcer": True, + "stream_retry": True, + "json_cleaner": True, + "description_patterns": len(ResponseAnalyzer.DESCRIPTION_PHRASES), }, "timestamp": int(time.time()) } - @app.get("/providers") async def get_providers(request: Request): verify_api_key(request) result = {} for pname, pobj in REAL_PROVIDERS.items(): models = discover_provider_models(pobj, pname) - result[pname] = { - "available": True, - "models": models - } + result[pname] = {"available": True, "models": models} return {"providers": result} - -# ===================================================== -# نقطة عرض سياق الجلسة -# ===================================================== @app.get("/context/{session_id}") async def get_session_context(session_id: str, request: Request): - """عرض سياق جلسة محددة - مفيد للتشخيص""" verify_api_key(request) ctx = CONTEXT_STORE.get_or_create_session(session_id) return { "session_id": session_id, "context": ctx, - "summary": CONTEXT_STORE.get_context_summary(session_id) + "summary": CONTEXT_STORE.get_context_summary(session_id), + "execution_directive": CONTEXT_STORE.get_execution_directive(session_id) } - @app.delete("/context/{session_id}") async def clear_session_context(session_id: str, request: Request): - """مسح سياق جلسة محددة""" verify_api_key(request) with CONTEXT_STORE._lock: if session_id in CONTEXT_STORE._store: @@ -2242,10 +2628,8 @@ async def clear_session_context(session_id: str, request: Request): return {"message": f"Session {session_id} cleared"} return {"message": f"Session {session_id} not found"} - @app.get("/context") async def list_sessions(request: Request): - """عرض قائمة الجلسات النشطة""" verify_api_key(request) sessions = [] with CONTEXT_STORE._lock: @@ -2254,18 +2638,115 @@ async def list_sessions(request: Request): "session_id": sid, "message_count": ctx.get("message_count", 0), "original_task_preview": ctx.get("original_task", "")[:100], - "current_state": ctx.get("current_state", "idle"), + "current_state": ctx.get("current_state", ExecutionState.IDLE), "completed_steps": len(ctx.get("completed_steps", [])), "tool_results": len(ctx.get("tool_results", [])), + "pending_tools": len(ctx.get("pending_tool_calls", [])), + "executed_tools": len(ctx.get("executed_tool_calls", [])), + "description_count": ctx.get("consecutive_description_count", 0), "last_updated": ctx.get("last_updated", 0), - "provider_count": len(ctx.get("provider_history", [])), }) return {"sessions": sessions, "total": len(sessions)} +@app.post("/debug/test-execution-enforcer") +async def debug_test_execution_enforcer(request: Request): + verify_api_key(request) + + test_cases = [ + { + "name": "Arabic description without execution", + "response": "سأقوم الآن بإنشاء الملف باستخدام أداة write_file.", + "expected_needs_retry": True + }, + { + "name": "English description without execution", + "response": "I will now create the file using the write_file tool.", + "expected_needs_retry": True + }, + { + "name": "Actual tool call", + "response": ( + "Creating the file now.\n" + "\n" + '{"name": "write_file", "arguments": {"path": "test.py", "content": "print(1)"}}\n' + "" + ), + "expected_needs_retry": False + }, + { + "name": "Let me create pattern", + "response": "Let me create a Python file with the requested functionality.", + "expected_needs_retry": True + }, + { + "name": "Regular response (no tools needed)", + "response": "The answer to your question is 42.", + "expected_needs_retry": False + }, + { + "name": "I'll use the tool pattern", + "response": "I'll use the write_file tool to save your code.", + "expected_needs_retry": True + }, + ] + + test_tools = [{ + "name": "write_file", + "description": "Write content to a file", + "input_schema": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "File path"}, + "content": {"type": "string", "description": "File content"} + }, + "required": ["path", "content"] + } + }] + + results = [] + for tc in test_cases: + analysis = ResponseAnalyzer.analyze(tc["response"], test_tools) + test_session = f"test_{uuid.uuid4().hex[:8]}" + enforcement = ExecutionEnforcer.post_process_response( + tc["response"], test_session, test_tools, [] + ) + # تنظيف الجلسة التجريبية + with CONTEXT_STORE._lock: + if test_session in CONTEXT_STORE._store: + del CONTEXT_STORE._store[test_session] + + results.append({ + "test_name": tc["name"], + "response_preview": tc["response"][:100], + "expected_needs_retry": tc["expected_needs_retry"], + "actual_needs_retry": enforcement["needs_retry"], + "passed": enforcement["needs_retry"] == tc["expected_needs_retry"], + "analysis": { + "has_actual_tool_call": analysis["has_actual_tool_call"], + "has_description_without_execution": ( + analysis["has_description_without_execution"] + ), + "intended_tool": analysis.get("intended_tool_name"), + }, + "retry_message_preview": ( + enforcement["retry_message"][:150] + if enforcement["retry_message"] + else "" + ) + }) + + passed = sum(1 for r in results if r["passed"]) + total = len(results) + return { + "test_results": results, + "summary": { + "passed": passed, + "failed": total - passed, + "total": total, + "success_rate": f"{passed/total*100:.1f}%" + } + } -# ===================================================== -# نقطة تشخيص: اختبار تحليل tool calls -# ===================================================== @app.get("/debug/test-tool-parse") async def debug_test_tool_parse(request: Request): verify_api_key(request) @@ -2276,15 +2757,15 @@ async def debug_test_tool_parse(request: Request): "input": ( 'I will create the file now.\n' '{"name": "write_file", ' - '"arguments": {"path": "test.py", "content": "print(\'hello\')"}}' + '"arguments": {"path": "test.py", "content": "print(\'hello\')"}}' '' ), }, { - "name": "Tool call with leading whitespace/newlines", + "name": "Tool call with whitespace", "input": ( 'Let me help you.\n\n\n' - ' \n' + ' \n' '{"name": "read_file", "arguments": {"path": "config.json"}}\n' '' ), @@ -2299,12 +2780,11 @@ async def debug_test_tool_parse(request: Request): ), }, { - "name": "Function tag format", + "name": "JSON with trailing comma (common error)", "input": ( - 'Creating file:\n' - '' - '{"path": "app.js", "content": "console.log(1)"}' - '' + '' + '{"name": "write_file", "arguments": {"path": "test.py", "content": "data",}}' + '' ), }, { @@ -2342,15 +2822,10 @@ async def debug_test_tool_parse(request: Request): return {"test_results": results} - -# ===================================================== -# نقطة تشخيص: اختبار stream buffer -# ===================================================== @app.post("/debug/test-stream-buffer") async def debug_test_stream_buffer(request: Request): verify_api_key(request) body = await request.json() - text = body.get("text", "") tools = body.get("tools", []) chunk_size = body.get("chunk_size", 10) @@ -2376,58 +2851,15 @@ async def debug_test_stream_buffer(request: Request): "events": all_events } - # ===================================================== -# نقطة تشخيص: اختبار PersistentContextStore +# نقطة الدردشة البسيطة # ===================================================== -@app.post("/debug/test-context-store") -async def debug_test_context_store(request: Request): - verify_api_key(request) - body = await request.json() - - test_session_id = f"test_{uuid.uuid4().hex[:8]}" - - # اختبار إنشاء جلسة - ctx = CONTEXT_STORE.get_or_create_session(test_session_id) - CONTEXT_STORE.update_session( - test_session_id, - original_task="Create a Python web scraper", - current_state="processing" - ) - - # اختبار إضافة خطوات - CONTEXT_STORE.add_completed_step(test_session_id, "Searched for Python scraping libraries") - CONTEXT_STORE.add_completed_step(test_session_id, "Found BeautifulSoup and requests") - - # اختبار إضافة نتائج أدوات - CONTEXT_STORE.add_tool_result( - test_session_id, - "web_search", - "toolu_test123", - "BeautifulSoup4, requests, scrapy are popular Python scraping libraries" - ) - - # الحصول على ملخص السياق - summary = CONTEXT_STORE.get_context_summary(test_session_id) - - # تنظيف الجلسة التجريبية - with CONTEXT_STORE._lock: - if test_session_id in CONTEXT_STORE._store: - del CONTEXT_STORE._store[test_session_id] - - return { - "test_session_id": test_session_id, - "context_summary": summary, - "test_passed": len(summary) > 0, - "summary_length": len(summary) - } - - @app.post("/chat") -async def chat(request: Request, chat_req: ChatRequest): +async def simple_chat(chat_req: ChatRequest, request: Request): verify_api_key(request) - session_id = chat_req.session_id or f"chat_{uuid.uuid4().hex[:16]}" - result = "" + session_id = chat_req.session_id or extract_session_id(request, {}) + + full_response = "" for chunk in ask( chat_req.message, chat_req.history, @@ -2435,14 +2867,19 @@ async def chat(request: Request, chat_req: ChatRequest): chat_req.model, session_id=session_id ): - result += chunk - return JSONResponse({"response": result, "session_id": session_id}) + full_response += chunk + return { + "response": full_response, + "session_id": session_id, + "provider": chat_req.provider, + "model": chat_req.model + } @app.post("/chat/stream") -async def chat_stream(request: Request, chat_req: ChatRequest): +async def simple_chat_stream(chat_req: ChatRequest, request: Request): verify_api_key(request) - session_id = chat_req.session_id or f"chat_{uuid.uuid4().hex[:16]}" + session_id = chat_req.session_id or extract_session_id(request, {}) async def generate(): for chunk in ask( @@ -2452,7 +2889,8 @@ async def chat_stream(request: Request, chat_req: ChatRequest): chat_req.model, session_id=session_id ): - yield f"data: {json.dumps({'delta': chunk}, ensure_ascii=False)}\n\n" + if chunk: + yield f"data: {json.dumps({'text': chunk}, ensure_ascii=False)}\n\n" yield "data: [DONE]\n\n" return StreamingResponse( @@ -2461,124 +2899,6 @@ async def chat_stream(request: Request, chat_req: ChatRequest): headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", + "X-Accel-Buffering": "no", } - ) - - -# ===================================================== -# معالجة الأخطاء العامة -# ===================================================== -@app.exception_handler(404) -async def not_found_handler(request: Request, exc: HTTPException): - return JSONResponse( - status_code=404, - content={ - "type": "error", - "error": { - "type": "not_found_error", - "message": ( - f"Endpoint {request.method} {request.url.path} not found" - ) - } - } - ) - - -@app.exception_handler(500) -async def internal_error_handler(request: Request, exc: Exception): - logger.error(f"Internal error: {str(exc)}") - return JSONResponse( - status_code=500, - content={ - "type": "error", - "error": { - "type": "api_error", - "message": "Internal server error" - } - } - ) - - -@app.exception_handler(401) -async def auth_error_handler(request: Request, exc: HTTPException): - return JSONResponse( - status_code=401, - content={ - "type": "error", - "error": { - "type": "authentication_error", - "message": ( - exc.detail if hasattr(exc, 'detail') else "Invalid API key" - ) - } - } - ) - - -@app.exception_handler(400) -async def bad_request_handler(request: Request, exc: HTTPException): - return JSONResponse( - status_code=400, - content={ - "type": "error", - "error": { - "type": "invalid_request_error", - "message": ( - exc.detail if hasattr(exc, 'detail') else "Bad request" - ) - } - } - ) - - -@app.exception_handler(429) -async def rate_limit_handler(request: Request, exc: HTTPException): - return JSONResponse( - status_code=429, - content={ - "type": "error", - "error": { - "type": "rate_limit_error", - "message": "Rate limit exceeded. Please retry after a brief wait." - } - } - ) - - -# ===================================================== -# مهمة تنظيف دورية للجلسات القديمة -# ===================================================== -@app.on_event("startup") -async def startup_event(): - """تشغيل مهام عند بدء الخادم""" - logger.info("Starting G4F Smart Router v4.0 (Fixed Version)") - logger.info(f"Cookies: {COOKIE_STATUS}") - logger.info(f"Providers: {list(REAL_PROVIDERS.keys())}") - logger.info("Key Fixes:") - logger.info(" ✅ Removed STRICT MODE (was breaking model responses)") - logger.info(" ✅ Added PersistentContextStore (prevents forgetting)") - logger.info(" ✅ Added SmartHistoryManager (intelligent compression)") - logger.info(" ✅ Fixed Fallback order (uses requested provider first)") - logger.info(" ✅ Balanced tool instructions (clear, not aggressive)") - logger.info(" ✅ Added session tracking across model switches") - - # بدء مهمة التنظيف الدورية - asyncio.create_task(periodic_cleanup()) - - -async def periodic_cleanup(): - """تنظيف دوري للجلسات القديمة كل ساعة""" - while True: - await asyncio.sleep(3600) # كل ساعة - CONTEXT_STORE.cleanup_old_sessions(max_age_hours=24) - logger.info("[Cleanup] Periodic cleanup completed") - - -# ===================================================== -# التشغيل -# ===================================================== -if __name__ == "__main__": - import uvicorn - port = int(os.getenv("PORT", 7860)) - logger.info(f"Starting G4F Smart Router v4.0 on port {port}") - uvicorn.run("app:app", host="0.0.0.0", port=port, reload=False) + ) \ No newline at end of file