অধ্যায় 6 · টুল ও ফাংশন কলিং
টুল চুক্তি ও এক্সিকিউশন
- পৃষ্ঠা 11 / 22
- 21 মিনিট পড়া
ঢাকার নাদিয়া একটা হেল্প-ডেস্ক অ্যাসিস্ট্যান্ট বানাচ্ছে। এক গ্রাহক জিজ্ঞেস করলেন, "পুরোনো রিটার্ন নীতি কি এখনো চলে?" মডেল নিজে ডকুমেন্ট স্টোর খুলতে পারে না। সে শুধু আপনার প্রোগ্রামকে অনুরোধ করতে পারে: "এই আইডি দিয়ে lookup_document চালান।" এরপর কী ঘটে, তার ওপরই ফিচারটা নিরাপদ কি না নির্ভর করে। এই পাতায় EvidenceDesk-এর সেই অংশ বানানো হবে, যা এমন অনুরোধ গ্রহণ করে, যাচাই করে, চালায় এবং পরিষ্কার কাঠামোয় ফল ফেরত দেয়।
AI-এর জন্য Git, API ও ব্যাকএন্ড: বিগিনার থেকে Pydantic আর নির্ভরযোগ্য আউটপুট চুক্তি থেকে ফলাফলের স্কিমা আপনার জানা। এখানে একই ধারণা আরেকটা দরজা পাহারা দেয়: মডেলের কথা আর আপনার কোডের মাঝের দরজা।
যা শিখবেন
- মডেলের টুল কল কেন একটা অনুরোধ, কে সেটা চালায়, আর রেজিস্ট্রি কীভাবে প্রতিটা টুলের নাম, বিবরণ, আর্গুমেন্ট স্কিমা ও হ্যান্ডলার রাখে।
evalছাড়া একটা রিড-অনলি ক্যালকুলেটর, আর কর্পাসের ওপর একটা ডকুমেন্ট লুকআপ কীভাবে বানাবেন।- Pydantic v2 দিয়ে আর্গুমেন্ট যাচাই, অচেনা টুলের নাম প্রত্যাখ্যান, ধীর টুলকে সময়ের সীমায় বাঁধা, এবং প্রতিটা ফলকে স্পষ্ট ডেটা হিসেবে ফেরত দেওয়া।
- বিভিন্ন প্রোভাইডারের ফরম্যাটকে একটা অভ্যন্তরীণ
ToolCall-এ কীভাবে মেলাবেন, আর ফেক ক্লায়েন্ট দিয়ে পুরো লুপ কীভাবে অফলাইনে টেস্ট করবেন।
মডেলের কল একটা অনুরোধ, কাজ নয়
টুল কলিংয়ে মডেল একটা টুল বেছে নেয় আর আর্গুমেন্টের প্রস্তাব দেয়। অ্যাপ্লিকেশন সেটা চালায়, অথবা প্রত্যাখ্যান করে। এ দুটো আলাদা ধাপ, আর মডেল শুধু প্রথমটায় অংশ নেয়। আপনার কোড না চালানো পর্যন্ত মডেলের লেখা কিছুই চলেনি। কোডেও আমরা ধাপ দুটো আলাদা রাখি, মাঝখানে যাচাই বসিয়ে:
model --request--> [ known name? ] --> [ valid arguments? ] --> [ run, with a time limit ]
|
model <--result-- [ envelope: ok + data, or ok=false + error ] <--+শুরু করি একটা অনুরোধকে সাধারণ ডেটা হিসেবে দিয়ে। এখানে "মডেল" শুধু আমাদের লেখা একটা ডিকশনারি। আসল মডেল কম অনুমানযোগ্য, আর ঠিক সেজন্যই টেস্ট করতে হয়।
# a recorded model request: just data. Nothing has run.
request = {"id": "c1", "name": "calculator", "arguments": {"expression": "12 * (3 + 4)"}}
print("requested:", request["name"], request["arguments"])requested: calculator {'expression': '12 * (3 + 4)'}প্রচলিত ভুল ধারণার দুটো সংশোধন এখানে দরকার। প্রম্পট গার্ডরেইল নয়: সিস্টেম প্রম্পটে "শুধু নিরাপদ টুল চালাও" লেখা একটা আশা, নিয়ন্ত্রণ নয়, তাই অনুমোদিত নাম আর আর্গুমেন্টের গড়ন কোডে, মডেলের বাইরে, প্রয়োগ করুন। আর টুলের আউটপুট অবিশ্বস্ত ইনপুট: কোনো ফলে ফাইল বা ওয়েব পেজের লেখা থাকতে পারে, আর সেই লেখা কখনো নির্দেশ নয়। এই প্রতিরক্ষা গড়া হবে টুলের অনুমতি ও ব্যর্থতা সামলানো এবং প্রম্পট ইনজেকশন ও অ্যাক্সেসের সীমানা পাতায়।
প্রোভাইডার নোট (২০২৬-১০-১২ অনুযায়ী): OWASP Top 10 for LLM Applications-এর ২০২৫ সংস্করণে "Excessive Agency" ঝুঁকি আছে, আর তার মূল কারণ হিসেবে "excessive functionality," "excessive permissions" ও "excessive autonomy" বলা হয়েছে। ২০২৬ সালের একটা নতুন সংস্করণও আছে, আর সংস্করণ বদলালে ঝুঁকির নম্বর বদলেছে, তাই ঝুঁকিকে নাম ও সংস্করণসহ উল্লেখ করুন। যুক্তরাজ্যের ন্যাশনাল সাইবার সিকিউরিটি সেন্টার (ব্লগ, ২০২৫-১২-০৮) বলে, SQL ইনজেকশনের মতো প্রম্পট ইনজেকশনও হয়তো কখনোই পুরোপুরি প্রশমিত হবে না, এবং পরামর্শ দেয় এমন নির্ধারিত সুরক্ষা ব্যবহার করতে, যা LLM-নির্ভর নয় আর সিস্টেমের কাজকে সীমিত করে (মূল উক্তি: "deterministic (non-LLM) safeguards that constrain the actions of the system")। এই পাতা সেই পরামর্শ মানে: সিদ্ধান্ত নেয় কোড, মডেল নয়। বর্তমান ডকুমেন্টেশন ও বর্তমান সংস্করণ দেখে নিন।
মডিউল: রেজিস্ট্রি, ক্যালকুলেটর, লুকআপ ও ডিসপ্যাচ
নিচের ফাইলটা kit/evidencedesk/tools.py নামে তৈরি করুন। kit/ যে ফোল্ডারে আছে সেখানে কাজ করুন। কিট হলো আগের পাতাগুলোর সম্পূর্ণ প্রজেক্ট ফোল্ডার (এর evidencedesk/, tests/, corpus/ ও data/ আপনার গড়া ফোল্ডারগুলোরই সমান), তাই এখন থেকে পাথ kit/ দিয়ে শুরু হবে। প্রতিটা টার্মিনালে একবার প্যাকেজটাকে ইমপোর্টযোগ্য করে নিন:
export PYTHONPATH=kit# file: kit/evidencedesk/tools.py
"""EvidenceDesk tools: registry, validation, dispatch (read-only tools)."""
from __future__ import annotations
import ast
import json
import operator
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeout
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
from pydantic import BaseModel, ConfigDict, Field, ValidationError
class ToolError(Exception):
"""An expected failure inside a tool. The code is safe to show to the model."""
def __init__(self, code: str, message: str):
super().__init__(message)
self.code, self.message = code, message
class ToolArgs(BaseModel):
"""Base class for every tool's arguments: unknown fields are rejected."""
model_config = ConfigDict(extra="forbid")
@dataclass(frozen=True)
class ToolCall:
"""The one internal shape of a model's request (a request, not an action)."""
id: str
name: str
arguments: dict[str, Any]
@dataclass(frozen=True)
class ToolResult:
"""Result envelope: ok=True carries data, ok=False carries an error."""
call_id: str
name: str
ok: bool
data: Any = None
error: dict[str, Any] | None = None
def as_dict(self) -> dict[str, Any]:
body = {"data": self.data} if self.ok else {"error": self.error}
return {"call_id": self.call_id, "name": self.name, "ok": self.ok, **body}
def as_message(self) -> dict[str, str]:
content = json.dumps(self.as_dict(), sort_keys=True, ensure_ascii=False)
return {"role": "tool", "tool_call_id": self.call_id, "content": content}
@dataclass(frozen=True)
class Tool:
name: str
description: str
args_model: type[BaseModel]
handler: Callable[[Any], Any]
def spec(self) -> dict[str, Any]:
schema = self.args_model.model_json_schema()
schema.pop("title", None)
return {"name": self.name, "description": self.description, "parameters": schema}
def _fail(call: ToolCall, code: str, message: str, **extra: Any) -> ToolResult:
return ToolResult(call.id, call.name[:40], False, error={"code": code, "message": message, **extra})
class ToolRegistry:
def __init__(self) -> None:
self._tools: dict[str, Tool] = {}
def register(self, tool: Tool) -> None:
if tool.name in self._tools:
raise ValueError(f"tool already registered: {tool.name}")
self._tools[tool.name] = tool
def names(self) -> list[str]:
return sorted(self._tools)
def specs(self) -> list[dict[str, Any]]:
return [self._tools[n].spec() for n in self.names()]
def execute(self, call: ToolCall, timeout: float = 2.0) -> ToolResult:
tool = self._tools.get(call.name)
if tool is None:
return _fail(call, "unknown_tool", "no such tool is registered")
try:
args = tool.args_model.model_validate(call.arguments)
except ValidationError as exc:
problems = [{"loc": ".".join(str(p) for p in e["loc"]), "msg": e["msg"]} for e in exc.errors()]
return _fail(call, "invalid_arguments", "arguments rejected", problems=problems)
pool = ThreadPoolExecutor(max_workers=1)
try:
data = pool.submit(tool.handler, args).result(timeout=timeout)
except FutureTimeout:
return _fail(call, "timeout", f"tool did not finish within {timeout} s")
except ToolError as exc:
return _fail(call, exc.code, exc.message)
except Exception: # the exception text may hold internals: do not pass it on
return _fail(call, "internal_error", "the tool failed")
finally:
pool.shutdown(wait=False)
return ToolResult(call.id, call.name, True, data=data)
# --- calculator: read-only, deterministic, no eval() ---------------------------------------------
_BINARY = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv, ast.Mod: operator.mod}
_UNARY = {ast.UAdd: operator.pos, ast.USub: operator.neg}
def safe_eval(expression: str) -> float | int:
"""Evaluate + - * / // % on plain numbers by walking the syntax tree."""
def walk(node: ast.AST) -> float | int:
if isinstance(node, ast.Expression):
return walk(node.body)
if isinstance(node, ast.Constant) and type(node.value) in (int, float):
return node.value
if isinstance(node, ast.BinOp) and type(node.op) in _BINARY:
return _BINARY[type(node.op)](walk(node.left), walk(node.right))
if isinstance(node, ast.UnaryOp) and type(node.op) in _UNARY:
return _UNARY[type(node.op)](walk(node.operand))
raise ToolError("bad_expression", f"not allowed: {type(getattr(node, 'op', node)).__name__}")
try:
tree = ast.parse(expression, mode="eval")
value = walk(tree)
except SyntaxError:
raise ToolError("bad_expression", "not a valid arithmetic expression") from None
except ZeroDivisionError:
raise ToolError("division_by_zero", "division by zero") from None
return round(value, 10) if isinstance(value, float) else value
class CalculatorArgs(ToolArgs):
expression: str = Field(min_length=1, max_length=100)
calculator_tool = Tool(
name="calculator",
description="Evaluate an arithmetic expression with + - * / // % and parentheses.",
args_model=CalculatorArgs,
handler=lambda args: {"value": safe_eval(args.expression)},
)
# --- document lookup over the corpus front matter ----------------------------------------------
class LookupArgs(ToolArgs):
doc_id: str = Field(pattern=r"^[a-z0-9-]{3,40}$")
def read_front_matter(path: Path) -> dict[str, str]:
lines = path.read_text(encoding="utf-8").splitlines()
meta: dict[str, str] = {}
for line in lines[1:]:
if line.strip() == "---":
break
key, _, value = line.partition(":")
meta[key.strip()] = value.strip()
return meta
def make_lookup_tool(corpus_dir: Path) -> Tool:
def handler(args: LookupArgs) -> dict[str, str]:
for path in sorted(corpus_dir.glob("*.md")):
meta = read_front_matter(path)
if meta.get("id") == args.doc_id:
return {k: meta[k] for k in ("id", "title", "version", "status", "lang")}
raise ToolError("not_found", "no document has that id")
return Tool("lookup_document", "Look up a help-centre document by its id.", LookupArgs, handler)
def default_registry(corpus_dir: Path) -> ToolRegistry:
registry = ToolRegistry()
registry.register(calculator_tool)
registry.register(make_lookup_tool(corpus_dir))
return registry
# --- provider shapes -> one ToolCall (dict in, dataclass out) ----------------------------------
def _arguments(raw: Any) -> dict[str, Any]:
if isinstance(raw, str): # some APIs send the arguments as a JSON string
try:
raw = json.loads(raw)
except json.JSONDecodeError:
raise ToolError("malformed_call", "arguments are not valid JSON") from None
if not isinstance(raw, dict):
raise ToolError("malformed_call", "arguments are not an object")
return raw
def from_openai(item: dict[str, Any]) -> ToolCall:
return ToolCall(item["call_id"], item["name"], _arguments(item["arguments"]))
def from_anthropic(block: dict[str, Any]) -> ToolCall:
return ToolCall(block["id"], block["name"], _arguments(block["input"]))
def from_gemini(step: dict[str, Any]) -> ToolCall:
return ToolCall(step["id"], step["name"], _arguments(step["arguments"]))
def from_ollama(call: dict[str, Any], index: int = 0) -> ToolCall:
fn = call["function"] # this shape has no call id: make a stable one from the position
return ToolCall(f"call_{index}", fn["name"], _arguments(fn["arguments"]))
# --- offline: a stand-in model and one step ----------------------------------------------------
class ScriptedToolModel:
"""Fake model: replays recorded requests. Real models are less predictable."""
def __init__(self, recorded: list[dict[str, Any]]):
self._recorded = list(recorded)
def next_call(self, question: str) -> dict[str, Any] | None:
return self._recorded.pop(0) if self._recorded else None
def run_one_step(model: ScriptedToolModel, registry: ToolRegistry, question: str) -> list[dict[str, Any]]:
transcript: list[dict[str, Any]] = [{"role": "user", "content": question}]
raw = model.next_call(question)
if raw is None:
return transcript
transcript.append({"role": "assistant", "tool_request": raw})
arguments = raw.get("arguments")
call = ToolCall(str(raw.get("id", "")), str(raw.get("name", "")), arguments if isinstance(arguments, dict) else {})
transcript.append(registry.execute(call).as_message())
return transcriptএই ক্রমে পড়ুন: Tool ও ToolRegistry; safe_eval ও দুটো টুল; দরজা execute; এনভেলপ ToolResult; চারটা from_* ম্যাপার; এবং অফলাইন লুপ ScriptedToolModel ও run_one_step।
টুল রেজিস্ট্রি বানানো
একটা টুল মানে চারটা জিনিস: মডেল যে নাম বলতে পারে, কখন ব্যবহার করতে হবে তা জানানো বিবরণ, অনুমোদিত ইনপুট ঠিক করে দেওয়া আর্গুমেন্ট মডেল, আর কাজ করা Python ফাংশন, অর্থাৎ হ্যান্ডলার। রেজিস্ট্রি এমন টুলের একটা ডিকশনারি, এর বেশি কিছু নয়। মডেল যা ছুঁতে পারে তার সম্পূর্ণ তালিকাও এটাই: কোনো ফাংশন রেজিস্টার না থাকলে কোনো মডেল-আউটপুট তাকে ডাকতে পারে না।
আর্গুমেন্ট মডেলগুলো ToolArgs থেকে উত্তরাধিকার নেয়, যেখানে extra="forbid" দেওয়া আছে। spec() যে JSON স্কিমা বানায় তা আসে সেই একই Pydantic ক্লাস থেকে, যে পরে ইনপুট যাচাই করে। তাই মডেলকে দেখানো চুক্তি আর আপনার কোডে প্রয়োগ করা চুক্তি আলাদা হয়ে যেতে পারে না।
from pprint import pprint
from pathlib import Path
from evidencedesk.tools import ToolCall, default_registry
registry = default_registry(Path("kit/corpus"))
print(registry.names())
spec = registry.specs()[0]
print(spec["description"])
pprint(spec["parameters"], width=76)['calculator', 'lookup_document']
Evaluate an arithmetic expression with + - * / // % and parentheses.
{'additionalProperties': False,
'properties': {'expression': {'maxLength': 100,
'minLength': 1,
'title': 'Expression',
'type': 'string'}},
'required': ['expression'],
'type': 'object'}স্কিমায় additionalProperties: false লক্ষ করুন। স্কিমা মডেলকে ভালো বাছাই করতে সাহায্য করে, কিন্তু মডেল তা উপেক্ষা করতে পারে, তাই আপনার কোড আবার যাচাই করে।
রিড-অনলি ক্যালকুলেটর টুল বানানো
ক্যালকুলেটর ভালো প্রথম টুল: নির্ধারিত ফল, কোনো পার্শ্বপ্রতিক্রিয়া নেই, নির্দিষ্ট কেস দিয়ে টেস্ট করা সহজ। লোভনীয় বাস্তবায়ন হলো eval(expression)। এটা করবেন না। eval শুধু পাটিগণিত নয়, যেকোনো Python চালায়। __import__('os').getcwd() লেখাটা এখানে নিরীহ, কিন্তু মেথডের নাম বদলালে একই লাইন ফাইল মুছে দেয় বা সিক্রেট পড়ে। লেখাটা আসে মডেল থেকে, যে হয়তো কোনো আক্রমণকারীর ডকুমেন্ট থেকে নকল করেছে, তাই eval ও exec কখনো তাকে স্পর্শ করে না।
safe_eval লেখাটাকে ast মডিউল দিয়ে একটা গাছে পার্স করে সেই গাছে হাঁটে। শুধু সংখ্যা, + - * / // % অপারেটর, আর সংখ্যার আগের চিহ্ন অনুমোদিত। বাকি সব প্রত্যাখ্যাত, নাম, কল, আর ** সহ (যা বিশাল সংখ্যা বানাতে পারে)। আউটপুটের প্রথম লাইনে দেখা যায় হাঁটার সময় কোন নোডগুলো চোখে পড়ে।
import ast
from evidencedesk.tools import ToolError, safe_eval
tree = ast.parse("12 * (3 + 4)", mode="eval")
print([type(node).__name__ for node in ast.walk(tree)])
for text in ["12 * (3 + 4)", "7 // 2 + 0.5", "__import__('os').getcwd()", "2 ** 999999", "1 / 0", "1 +"]:
try:
print(f"{text!r:28} -> {safe_eval(text)}")
except ToolError as exc:
print(f"{text!r:28} -> refused: {exc.code} ({exc.message})")['Expression', 'BinOp', 'Constant', 'Mult', 'BinOp', 'Constant', 'Add', 'Constant']
'12 * (3 + 4)' -> 84
'7 // 2 + 0.5' -> 3.5
"__import__('os').getcwd()" -> refused: bad_expression (not allowed: Call)
'2 ** 999999' -> refused: bad_expression (not allowed: Pow)
'1 / 0' -> refused: division_by_zero (division by zero)
'1 +' -> refused: bad_expression (not a valid arithmetic expression)প্রত্যাখ্যান হলো ছোট কোডসহ ToolError, ক্র্যাশ নয়। দ্বিতীয় টুল lookup_document কর্পাস ফাইলের ফ্রন্ট ম্যাটার (id, title, version, status, language) পড়ে ফেরত দেয়। এটাও রিড-অনলি, আর ডকুমেন্টের মূল লেখা ফেরত দেয় না।
Pydantic দিয়ে টুল আর্গুমেন্ট যাচাই
মডেলের আর্গুমেন্ট অবিশ্বস্ত ইনপুট, ইন্টারনেট থেকে আসা JSON বডির মতো। হ্যান্ডলার কিছু দেখার আগেই execute model_validate চালায়। তিনটা ব্যর্থতা গুরুত্বপূর্ণ: অনুপস্থিত ফিল্ড, বাড়তি ফিল্ড (extra="forbid"-এর কারণে প্রত্যাখ্যাত) আর অবৈধ মান (ভুল টাইপ, দৈর্ঘ্য বা প্যাটার্ন)। Pydantic-এর এরর অবজেক্টে ডকুমেন্টেশন URL আর ইনপুট মানও থাকে, তাই আমরা ফলে শুধু loc ও msg কপি করি।
def show(name, **arguments):
result = registry.execute(ToolCall("c1", name, arguments))
print(name, arguments)
print(" ->", result.error["code"] if not result.ok else result.data)
for problem in (result.error or {}).get("problems", []):
print(" ", problem["loc"], "|", problem["msg"])
show("calculator")
show("calculator", expression="1+1", mode="fast")
show("calculator", expression=42)
show("calculator", expression="2*21")calculator {}
-> invalid_arguments
expression | Field required
calculator {'expression': '1+1', 'mode': 'fast'}
-> invalid_arguments
mode | Extra inputs are not permitted
calculator {'expression': 42}
-> invalid_arguments
expression | Input should be a valid string
calculator {'expression': '2*21'}
-> {'value': 42}শেষ লাইনে সফল ফল। বৈধ আর্গুমেন্ট শুধু প্রমাণ করে যে গড়ন ঠিক, অনুরোধটা বুদ্ধিমানের কাজ কি না তা নয়।
শুধু রেজিস্টার করা টুল ডিসপ্যাচ করা
ডিসপ্যাচ হলো নামের হুবহু ডিকশনারি লুকআপ: getattr নয়, globals()[name] নয়, প্রিফিক্স মেলানোও নয়। অচেনা নামে unknown_tool ফেরত আসে, আর উত্তরে নামটা ৪০ অক্ষরে কেটে দেওয়া হয়। ব্লকটা লুকআপ টুলও চালায়: ঠিক আইডি, অচেনা আইডি, আর পাথ-ট্র্যাভার্সাল মনে হওয়া আইডি দিয়ে, যা কোনো ফাইল ছোঁয়ার আগেই pattern নিয়মে আটকে যায়।
টাইমআউট ও অপ্রত্যাশিত এরর
টুল ব্যর্থ হয়, আবার আটকেও যায়। execute হ্যান্ডলারকে একটা ওয়ার্কার থ্রেডে চালায় এবং সীমিত সময় অপেক্ষা করে; অপেক্ষা শেষ হলে timeout ফল পাওয়া যায় আর ফিচার এগিয়ে যায়। হ্যান্ডলার অপ্রত্যাশিত এক্সেপশন ছুড়লে ফলে শুধু "the tool failed" (টুলটি ব্যর্থ হয়েছে) থাকে, কারণ এক্সেপশনের লেখায় পাথ বা সিক্রেট থাকতে পারে। একটা সৎ সীমাবদ্ধতা: Python কোনো থ্রেডকে জোর করে থামাতে পারে না, তাই টাইমআউট হওয়া হ্যান্ডলার চলতে থাকতে পারে। রিড-অনলি টুলের জন্য এটা গ্রহণযোগ্য, আর শুরুর টুলগুলো রিড-অনলি রাখার একটা শক্ত কারণও। নিচের ধীর টুল একটা ইভেন্টের জন্য অপেক্ষা করে, তাই ডেমো ঘড়ির ওপর নির্ভর করে না।
import threading
from evidencedesk.tools import Tool, ToolArgs
show("delete_everything", path="/")
show("lookup_document", doc_id="kc-warranty-v2")
show("lookup_document", doc_id="kc-nope")
show("lookup_document", doc_id="../../etc/passwd")
release = threading.Event() # lets the slow handler stop when we are done
class NoArgs(ToolArgs):
pass
registry.register(Tool("slow", "waits for a long time", NoArgs, lambda args: release.wait(5)))
timed_out = registry.execute(ToolCall("c2", "slow", {}), timeout=0.05)
release.set()
print("slow ->", timed_out.error)delete_everything {'path': '/'}
-> unknown_tool
lookup_document {'doc_id': 'kc-warranty-v2'}
-> {'id': 'kc-warranty-v2', 'title': 'Warranty', 'version': '2', 'status': 'current', 'lang': 'en'}
lookup_document {'doc_id': 'kc-nope'}
-> not_found
lookup_document {'doc_id': '../../etc/passwd'}
-> invalid_arguments
doc_id | String should match pattern '^[a-z0-9-]{3,40}$'
slow -> {'code': 'timeout', 'message': 'tool did not finish within 0.05 s'}টুলের ফল স্পষ্ট ডেটা হিসেবে ফেরত দেওয়া
সফল বা ব্যর্থ, প্রতিটা ফল একই এনভেলপে আসে: call_id, name, ok, আর হয় data নয় error। call_id ফলকে অনুরোধের সঙ্গে বাঁধে। as_message() এনভেলপকে সেই JSON লেখা বানায়, যা মডেলের প্রবাহে ফেরত যায়। কি-গুলো সাজানো, তাই লেখাটা স্থির আর টেস্টে তুলনা করা সহজ।
ok = registry.execute(ToolCall("c3", "lookup_document", {"doc_id": "kc-returns-v2"}))
bad = registry.execute(ToolCall("c4", "calculator", {"expression": "2 ** 9"}))
import json
from pprint import pprint
for result in (ok, bad):
pprint(json.loads(result.as_message()["content"]), width=76){'call_id': 'c3',
'data': {'id': 'kc-returns-v2',
'lang': 'en',
'status': 'superseded',
'title': 'Returns policy (old)',
'version': '2'},
'name': 'lookup_document',
'ok': True}
{'call_id': 'c4',
'error': {'code': 'bad_expression', 'message': 'not allowed: Pow'},
'name': 'calculator',
'ok': False}দ্বিতীয় ফলটা এমন ব্যর্থতা, যা মডেল বুঝে প্রতিক্রিয়া দেখাতে পারে। প্রথমটা এমন সাফল্য, যা একটা superseded (বাতিল) ডকুমেন্ট ফেরত দেয়: টুল তার কাজ করেছে, কিন্তু অ্যাপ্লিকেশনকে তবু ডেটা যাচাই করতে হবে। যাচাই পাস করা মানেই সঠিক উত্তর নয়। বার্তার কনটেন্ট হলো এই ডিকশনারিই, JSON লেখা আকারে।
ফেক টুল-কলিং ক্লায়েন্ট বানানো
ScriptedToolModel আগে রেকর্ড করা অনুরোধগুলো আবার চালিয়ে দেয়, তাই নেটওয়ার্ক, কি বা মডেল ছাড়াই এক্সিকিউশন লুপ টেস্ট হয়। এটা একটা স্ট্যান্ড-ইন: সে কখনো সিদ্ধান্ত নেয় না, আর পুনরাবৃত্তিযোগ্য টেস্টের জন্য সেটাই দরকার। run_one_step তাকে একবার জিজ্ঞেস করে, অনুরোধটা রেজিস্ট্রির মধ্য দিয়ে চালায়, এবং ট্রান্সক্রিপ্ট ফেরত দেয়, যা আসল মডেলে পাঠানোর জন্য তৈরি।
from pprint import pprint
from evidencedesk.tools import ScriptedToolModel, run_one_step
model = ScriptedToolModel([{"id": "c5", "name": "lookup_document", "arguments": {"doc_id": "kc-warranty-v2"}}])
for message in run_one_step(model, registry, "Which document is the warranty?"):
pprint(message, width=76)
print(len(run_one_step(ScriptedToolModel([]), registry, "Hello")), "message when the model asks for nothing"){'content': 'Which document is the warranty?', 'role': 'user'}
{'role': 'assistant',
'tool_request': {'arguments': {'doc_id': 'kc-warranty-v2'},
'id': 'c5',
'name': 'lookup_document'}}
{'content': '{"call_id": "c5", "data": {"id": "kc-warranty-v2", "lang": '
'"en", "status": "current", "title": "Warranty", "version": '
'"2"}, "name": "lookup_document", "ok": true}',
'role': 'tool',
'tool_call_id': 'c5'}
1 message when the model asks for nothingট্রান্সক্রিপ্টে তিনটা বার্তা: প্রশ্ন, অনুরোধ আর টুল বার্তা। মডেল কিছু না চাইলে কিছুই চলে না।
প্রোভাইডারের টুল-কলিং ফরম্যাটের তুলনা
প্রোভাইডাররা টুল অনুরোধ আলাদাভাবে বর্ণনা করে: আর্গুমেন্ট JSON স্ট্রিং বা অবজেক্ট হতে পারে, আর কল আইডি নাও থাকতে পারে। এই পার্থক্য কোডের সর্বত্র ছড়াবেন না। কিনারায় প্রতিটা গড়নকে একটা অভ্যন্তরীণ ToolCall-এ মেলান। ম্যাপারগুলো সাধারণ ডিকশনারি-ইন, ডেটাক্লাস-আউট কোড, তাই ডকুমেন্টেড গড়ন অনুসরণ করে আমাদের হাতে লেখা ছোট ডিকশনারির ওপর চালাই। এগুলো উদাহরণ, আসল রেসপন্স নয়।
প্রোভাইডার নোট (২০২৬-১০-১২ অনুযায়ী): OpenAI (Responses API) একটা সমতল টুল ঘোষণা করে
{"type": "function", "name", "description", "parameters"}; রেসপন্সেfunction_callটাইপের আইটেমেcall_id,nameআর JSON স্ট্রিং আকারেargumentsথাকে; ফল ফেরত যায়function_call_outputআইটেম হিসেবে। Anthropic ঘোষণা করেinput_schema; রেসপন্সেstop_reason == "tool_use"এবংid,name,input(আগে থেকেই অবজেক্ট) সহtool_useব্লক থাকে; ফল ফেরত যায়tool_use_idসহtool_resultব্লকে। Gemini-র দুটো পথ: Interactions API (function_callটাইপের ধাপেname,arguments,id), যেটাকে তার ডকুমেন্টেশন সুপারিশ করে, আর পুরোনোgenerateContentপথ। Ollamamessage.tool_callsফেরত দেয়, যেখানেfunction.nameআর পার্স করাfunction.argumentsথাকে; টুল সাপোর্ট মডেলভেদে আলাদা। কিছু Anthropic মডেল জোর করে দেওয়াtool_choiceপ্রত্যাখ্যান করে। Geminiarguments-এর টাইপ আমাদের পড়া পাতাগুলোয় বলা নেই, তাই_argumentsস্ট্রিং ও অবজেক্ট দুটোই নেয়। বর্তমান ডকুমেন্টেশন দেখে নিন।
import json
from evidencedesk.tools import from_anthropic, from_gemini, from_ollama, from_openai
# Hand-written examples that follow the documented shapes. They are NOT real responses.
args = {"expression": "12 * (3 + 4)"}
openai_item = {"type": "function_call", "call_id": "call_1", "name": "calculator", "arguments": json.dumps(args)}
anthropic_block = {"type": "tool_use", "id": "toolu_1", "name": "calculator", "input": args}
gemini_step = {"type": "function_call", "id": "fc_1", "name": "calculator", "arguments": args}
ollama_call = {"function": {"name": "calculator", "arguments": args}}
for call in (from_openai(openai_item), from_anthropic(anthropic_block), from_gemini(gemini_step), from_ollama(ollama_call)):
print(call)ToolCall(id='call_1', name='calculator', arguments={'expression': '12 * (3 + 4)'})
ToolCall(id='toolu_1', name='calculator', arguments={'expression': '12 * (3 + 4)'})
ToolCall(id='fc_1', name='calculator', arguments={'expression': '12 * (3 + 4)'})
ToolCall(id='call_0', name='calculator', arguments={'expression': '12 * (3 + 4)'})চার ভিন্ন গড়ন, চারটা অভিন্ন ফল। চতুর্থ কলে ডকুমেন্টেড উদাহরণে আইডি নেই, তাই from_ollama অবস্থান থেকে একটা আইডি বানায়। বিকৃত আর্গুমেন্ট স্ট্রিং খারাপ ডেটা এগিয়ে না দিয়ে ToolError("malformed_call") ছোড়ে। আসল SDK কল দেখতে নিচের ব্লকের মতো। ব্লকটায় # not run here লেখা, কারণ এর জন্য কি ও নেটওয়ার্ক লাগে, আর আমরা চালাইনি বলে কোনো আউটপুট দেখাচ্ছি না। SDK অবজেক্ট শুধু বাহক; যাচাই করে আপনার রেজিস্ট্রি। নামগুলো VERIFY — checked 2026-10-12 হিসেবে ধরুন।
# not run here: needs a provider SDK, an API key and network access
# Responses-style call (OpenAI SDK)
specs = [{"type": "function", **spec} for spec in registry.specs()]
reply = client.responses.create(model=MODEL, tools=specs, input=messages)
messages += reply.output
for item in reply.output:
if item.type == "function_call":
result = registry.execute(from_openai({"call_id": item.call_id, "name": item.name, "arguments": item.arguments}))
messages.append({"type": "function_call_output", "call_id": item.call_id, "output": result.as_message()["content"]})
# the Messages-style call of the other SDK differs only in shape: from_anthropic() maps its tool_use blocksঅনুশীলনী: নিরাপদ এক-ধাপের টুল কল
নিচের টেস্ট ফাইল এমন একটা মডেলকে অনুকরণ করে যে রিড-অনলি টুল বাছে, আর এই পাতার প্রতিটা নিয়ম পরীক্ষা করে: অনুপস্থিত, বাড়তি ও অবৈধ আর্গুমেন্ট, অচেনা নাম, প্রত্যাখ্যাত এক্সপ্রেশন, টাইমআউট, যে এক্সেপশন ফাঁস হওয়া চলবে না, প্রোভাইডার ম্যাপিং, আর পুরো এক-ধাপের রান। ফাইলটা kit/tests/test_tools.py নামে সেভ করে চালান।
# file: kit/tests/test_tools.py
import json
import threading
from pathlib import Path
import pytest
from evidencedesk.tools import (ScriptedToolModel, Tool, ToolArgs, ToolCall, ToolError, ToolRegistry, default_registry,
from_anthropic, from_gemini, from_ollama, from_openai, run_one_step, safe_eval)
CORPUS = Path(__file__).resolve().parents[1] / "corpus"
@pytest.fixture
def registry():
return default_registry(CORPUS)
def call(name, **arguments):
return ToolCall("c1", name, arguments)
def test_calculator_ok(registry):
result = registry.execute(call("calculator", expression="12 * (3 + 4) - 10 / 4"))
assert result.ok and result.data == {"value": 81.5}
@pytest.mark.parametrize("bad", ["__import__('os')", "2 ** 3", "abs(-1)", "x + 1", "'a' * 3", "True + 1", "1 +"])
def test_safe_eval_refuses_everything_but_arithmetic(bad):
with pytest.raises(ToolError):
safe_eval(bad)
def test_missing_extra_and_invalid_arguments(registry):
missing = registry.execute(call("calculator"))
extra = registry.execute(call("calculator", expression="1+1", mode="fast"))
invalid = registry.execute(call("calculator", expression=42))
for r in (missing, extra, invalid):
assert r.error["code"] == "invalid_arguments"
assert missing.error["problems"][0]["loc"] == "expression"
assert extra.error["problems"][0]["loc"] == "mode"
assert all("url" not in p for p in invalid.error["problems"])
def test_unknown_tool_is_rejected(registry):
result = registry.execute(call("delete_everything", path="/"))
assert result.error["code"] == "unknown_tool"
def test_lookup_document(registry):
ok = registry.execute(call("lookup_document", doc_id="kc-warranty-v2"))
assert ok.data["title"] == "Warranty" and ok.data["status"] == "current"
assert registry.execute(call("lookup_document", doc_id="kc-nope")).error["code"] == "not_found"
assert registry.execute(call("lookup_document", doc_id="../../etc/passwd")).error["code"] == "invalid_arguments"
def test_timeout_and_unexpected_exception():
release = threading.Event()
class NoArgs(ToolArgs):
pass
def boom(_):
raise RuntimeError("secret internal detail")
registry = ToolRegistry()
registry.register(Tool("slow", "waits", NoArgs, lambda _: release.wait(5)))
registry.register(Tool("boom", "fails", NoArgs, boom))
try:
assert registry.execute(call("slow"), timeout=0.05).error["code"] == "timeout"
finally:
release.set()
failed = registry.execute(call("boom"))
assert failed.error == {"code": "internal_error", "message": "the tool failed"}
def test_provider_shapes_map_to_one_call():
args = {"expression": "1+1"}
calls = [
from_openai({"type": "function_call", "call_id": "a", "name": "calculator", "arguments": json.dumps(args)}),
from_anthropic({"type": "tool_use", "id": "a", "name": "calculator", "input": args}),
from_gemini({"type": "function_call", "id": "a", "name": "calculator", "arguments": args}),
from_ollama({"function": {"name": "calculator", "arguments": args}}),
]
assert {(c.name, json.dumps(c.arguments)) for c in calls} == {("calculator", json.dumps(args))}
with pytest.raises(ToolError):
from_openai({"call_id": "a", "name": "calculator", "arguments": "{not json"})
def test_safe_one_step(registry):
model = ScriptedToolModel([{"id": "c9", "name": "lookup_document", "arguments": {"doc_id": "kc-returns-v2"}}])
transcript = run_one_step(model, registry, "Which returns document is old?")
body = json.loads(transcript[-1]["content"])
assert body["ok"] and body["data"]["status"] == "superseded"
assert len(transcript) == 3python -m pytest -q kit/tests/test_tools.py | sed 's/ in [0-9.]*s//'.............. [100%]
14 passedএটাই পাস লাইন; sed সেকেন্ডগুলো সরিয়ে দেয়, কারণ প্রতি রানে তা বদলায়। এই টেস্ট যা দেখাতে পারে না: আসল মডেল ঠিক টুল বাছবে বা বুদ্ধিমান আর্গুমেন্ট লিখবে। টেস্ট শুধু প্রমাণ করে, যা-ই দেওয়া হোক আপনার কোড টিকে থাকে।
কাজের জায়গায় এটা কোথায় দেখা যায়
কোম্পানির ডেটা ছোঁয় এমন অ্যাসিস্ট্যান্ট (অর্ডারের অবস্থা, টিকিট লুকআপ, কোটেশন) এই প্যাটার্ন ব্যবহার করে। আক্রার কোয়ামে এমন ফিচার রিভিউ করেন, আর দুটো প্রশ্ন করেন: মডেল কী চেয়েছিল, আর আসলে কী চলেছে? এনভেলপসহ রেজিস্ট্রি দুটোরই উত্তর লগ থেকে দেয়, আর অচেনা-টুলের ফল প্রথম ইঙ্গিত দেয় যে কেউ সিস্টেম খুঁচিয়ে দেখছে।
সাধারণ ভুল
- মডেলের লেখায়
evalব্যবহার। এটা যেকোনো Python চালায়, আর লেখাটা আক্রমণকারীর হতে পারে।astদিয়ে পার্স করুন এবং শুধু দরকারি নোড অনুমোদন করুন। - মডেলকে দেখানো স্কিমার ওপর ভরসা করা। মডেল তা উপেক্ষা করতে পারে। যাচাই নিজের কোডে করুন।
- কাঁচা এক্সেপশন বা Pydantic এরর ফেরত দেওয়া। এতে পাথ, মান ও লিংক ফাঁস হতে পারে। শুধু কোড আর
loc/msgফেরত দিন। getattrবাglobals()দিয়ে ডিসপ্যাচ। তখন মডেলের লেখাই ঠিক করে কোন ফাংশন চলবে। স্পষ্ট রেজিস্ট্রি ব্যবহার করুন।- টাইমআউটে কাজ থেমে যায় ভাবা। থামে শুধু অপেক্ষা; শুরুর টুলগুলো রিড-অনলি রাখুন।
নিজে চেষ্টা করুন
- সহজ।
-5 + 10 % 4-এর জন্য ক্যালকুলেটরের ফল আগে অনুমান করুন, তারপর রেজিস্ট্রির মধ্য দিয়ে চালান। - মাঝারি। নির্ভরযোগ্য আউটপুট চুক্তি আবার দেখুন:
Envelopeনামে একটা Pydantic মডেল লিখুন, যার ভ্যালিডেটরokসত্য হলে ডেটা আর মিথ্যা হলে এরর চাইবে। একটা ঠিক আর একটা ভুল এনভেলপ পরীক্ষা করুন। - কঠিন। তৃতীয় রিড-অনলি টুল
count_documentsযোগ করুন, যার একমাত্র আর্গুমেন্টstatus, হয়"current"নয়"superseded"। রেজিস্টার করে দেখান যে"draft"প্রত্যাখ্যাত হয়।
উত্তর
সহজ। মডুলো যোগের আগে বাঁধে: 10 % 4 হলো 2, আর -5 + 2 হলো -3।
result = registry.execute(ToolCall("e1", "calculator", {"expression": "-5 + 10 % 4"}))
print(result.ok, result.data)True {'value': -3}মাঝারি। ভ্যালিডেটর এমন একটা বিজনেস রুল দেখে, যা শুধু টাইপ দিয়ে বলা যায় না।
from pydantic import BaseModel, ValidationError, model_validator
class Envelope(BaseModel):
call_id: str
name: str
ok: bool
data: object = None
error: dict | None = None
@model_validator(mode="after")
def data_xor_error(self):
broken = (self.data is None or self.error is not None) if self.ok else self.error is None
if broken:
raise ValueError("an ok result needs data and no error; a failure needs an error")
return self
print(Envelope(**ok.as_dict()).ok, Envelope(**bad.as_dict()).ok)
try:
Envelope(call_id="x", name="calculator", ok=True, error={"code": "x"})
except ValidationError as exc:
print(exc.errors()[0]["msg"])True False
Value error, an ok result needs data and no error; a failure needs an errorকঠিন। হ্যান্ডলার read_front_matter আবার কাজে লাগায়। Literal টাইপ হ্যান্ডলার চলার আগেই বাকি সব স্ট্যাটাস প্রত্যাখ্যান করায়।
from pathlib import Path
from typing import Literal
from evidencedesk.tools import read_front_matter
class CountArgs(ToolArgs):
status: Literal["current", "superseded"]
def count_documents(args):
metas = [read_front_matter(p) for p in Path("kit/corpus").glob("*.md")]
return {"status": args.status, "count": sum(m.get("status") == args.status for m in metas)}
registry.register(Tool("count_documents", "Count documents with a given status.", CountArgs, count_documents))
for status in ("current", "superseded", "draft"):
result = registry.execute(ToolCall("h1", "count_documents", {"status": status}))
print(status, "->", result.data if result.ok else result.error["problems"])current -> {'status': 'current', 'count': 12}
superseded -> {'status': 'superseded', 'count': 1}
draft -> [{'loc': 'status', 'msg': "Input should be 'current' or 'superseded'"}]পাস চেকলিস্ট:
- সহজ: ছাপা মান -3 এবং
okসত্য। - মাঝারি: ঠিক এনভেলপ যাচাই পাস করে, আর ভুলটা আপনার বার্তাসহ ভ্যালিডেশন এরর ছোড়ে।
- কঠিন: গণনা 12 ও 1, আর "draft"
locstatusসহ একটাinvalid_argumentsসমস্যা দেয়। - কোনো উত্তরে
evalনেই, Pydantic URL ছাপা হয়নি,kit/corpus-এর বাইরে কিছু পড়া হয়নি; নতুন টুলের জন্য pytest কেস আছে।
সারসংক্ষেপ
- মডেল টুল কলের প্রস্তাব দেয়; আপনার কোড যাচাই করে চালায়, অথবা প্রত্যাখ্যান করে।
- নাম, বিবরণ, আর্গুমেন্ট মডেল ও হ্যান্ডলারের রেজিস্ট্রিই নাগালের মধ্যে থাকা সব কাজের সম্পূর্ণ তালিকা।
- আর্গুমেন্ট অবিশ্বস্ত: Pydantic v2 ও
extra="forbid"দিয়ে যাচাই করুন, শুধুlocওmsgজানান, অচেনা নাম প্রত্যাখ্যান করুন। - পাটিগণিতে
ast, কখনোevalনয়। প্রতিটা কলে টাইমআউট দিন আর প্রতিটা ফল স্পষ্ট এনভেলপে ফেরত দিন। - প্রোভাইডার ফরম্যাটগুলো একটা
ToolCall-এ মেলান আর স্ক্রিপ্টেড ফেক ক্লায়েন্ট দিয়ে লুপ টেস্ট করুন।
এরপর: টুলের অনুমতি ও ব্যর্থতা সামলানো পাতায় এক্সিকিউশনের চারপাশের নিয়ম যোগ হবে: কোন কাজে কোন টুল চলবে, গুরুতর পরিণতির কাজে নিশ্চিতকরণ, কলের সীমা আর অডিট ইভেন্ট।