الدرس 3 من 10

من Function لـ Tool أوتوماتيك

هنستخدم اللي اتعلمناه في المرحلة ٢ عشان نبني نظام أدوات نضيف.

المشكلة

لو عندك ٢٠ أداة، كتابة الـ schema لكل واحدة بإيدك مملة ومصدر أخطاء: تغير parameter في الدالة وتنسى تغيره في الـ schema. الأحسن إن الـ schema تتولد من الدالة نفسها: الأنواع من الـ type hints، والوصف من الـ docstring.

هنكتب decorator (فاكرهم من المرحلة ٢؟) بيعمل ده، وبيسجل كل أداة في registry واحد:

tools_registry.py
import inspect
import json

TYPE_MAP = {int: "integer", float: "number", str: "string", bool: "boolean"}
TOOLS = {}                                  # name -> {"function", "schema"}

def tool(func):
    """Register a function as an agent tool, building its schema automatically."""
    props, required = {}, []
    for name, param in inspect.signature(func).parameters.items():
        props[name] = {"type": TYPE_MAP.get(param.annotation, "string")}
        if param.default is inspect.Parameter.empty:
            required.append(name)            # no default -> required
    TOOLS[func.__name__] = {
        "function": func,
        "schema": {
            "name": func.__name__,
            "description": inspect.getdoc(func),
            "input_schema": {"type": "object", "properties": props, "required": required},
        },
    }
    return func

# ---------------- our store's tools ----------------
ORDERS = {"1024": {"status": "out for delivery", "eta": "today before 9 PM", "total": 1850},
          "1031": {"status": "delivered", "eta": None, "total": 640}}

@tool
def get_order_status(order_id: str) -> str:
    """Get the status and delivery estimate of an order by its 4-digit number.
    Use when the customer asks about a specific order."""
    order = ORDERS.get(order_id)
    if order is None:
        raise ValueError(f"No order with number {order_id}. Ask the customer to double-check it.")
    return json.dumps(order)

@tool
def shipping_cost(governorate: str, order_total: float) -> str:
    """Calculate shipping cost in EGP for a governorate and order total.
    Orders above 1000 EGP ship free."""
    if order_total > 1000:
        return "0"
    return "50" if governorate.lower() in ("cairo", "giza") else "75"

if __name__ == "__main__":
    for t in TOOLS.values():
        print(json.dumps(t["schema"], indent=2))
        print("---")

كتبنا الدالة مرة واحدة، والـ schema اتولدت لوحدها. order_total بقت number لأنها float، والاتنين إجباريين لأن مفيش قيم افتراضية.

الـ Dispatcher: ينفذ أي أداة بالاسم

لما الموديل يطلب أداة، بيبعت اسمها ومدخلاتها. محتاجين دالة واحدة تنفذ أي أداة بأمان، وترجع النتيجة أو رسالة خطأ واضحة بدل ما البرنامج يقع:

dispatcher.py
from tools_registry import TOOLS

def run_tool(name: str, tool_input: dict) -> tuple[str, bool]:
    """Execute a registered tool. Returns (content, is_error)."""
    if name not in TOOLS:
        return f"Unknown tool: {name}", True
    try:
        result = TOOLS[name]["function"](**tool_input)
        return str(result), False
    except TypeError as e:                     # wrong/missing arguments
        return f"Invalid arguments: {e}", True
    except Exception as e:                     # the tool itself failed
        return f"Error: {e}", True

if __name__ == "__main__":                     # demo only when run directly
    print(run_tool("get_order_status", {"order_id": "1024"}))
    print(run_tool("get_order_status", {"order_id": "9999"}))
    print(run_tool("shipping_cost", {"governorate": "Aswan", "order_total": 640}))
    print(run_tool("shipping_cost", {"governorate": "Cairo"}))   # missing argument
    print(run_tool("delete_everything", {}))

كل حالة فشل رجعت رسالة مفهومة مع True. الرسالة دي هتروح للموديل، وهو هيفهم إيه اللي حصل: يسأل العميل عن رقم الأوردر تاني، أو يكمل الـ parameter الناقص. الـ agent الكويس مش اللي مبيغلطش، ده اللي بيعرف يتصرف لما يغلط.

💡 رسالة الخطأ موجهة للموديلاكتبها كأنها تعليمات: «No order with number 9999. Ask the customer to double-check it» أحسن بكتير من «KeyError: 9999». الموديل هيتصرف على أساس الكلام ده.
الدرس اللي فات