cwkPippa Claude 베리언트는 Agent SDK의 built-in tool support 사용, but ChatGPT랑 Gemini 베리언트는 Tool Runner 패턴 구현. 같은 registry, 같은 dispatch 모양, just 다른 upstream API. 패턴이 프로바이더 사이 이동에 살아남아.
원칙: 셋째 도구 후 루프 추출. 더 일찍은 premature; 더 늦은 건 같은 코드 다시 쓰는 거.
Code
Minimal Tool Runner·python
import asyncio, json
from dataclasses import dataclass, field
from typing import Callable, Awaitable
@dataclass
class ToolRunner:
client: AsyncAnthropic
model: str
tools: list[dict]
handlers: dict[str, Callable[..., Awaitable[dict]]]
max_iters: int = 10
on_tool_call: Callable[[str, dict], None] | None = None
async def _dispatch(self, blocks):
async def one(b):
if self.on_tool_call:
self.on_tool_call(b.name, b.input)
try:
out = await self.handlers[b.name](**b.input)
return {"type": "tool_result", "tool_use_id": b.id, "content": json.dumps(out)}
except Exception as e:
return {"type": "tool_result", "tool_use_id": b.id, "is_error": True, "content": str(e)}
return await asyncio.gather(*(one(b) for b in blocks))
async def run(self, system: str, user: str) -> str:
messages = [{"role": "user", "content": user}]
for _ in range(self.max_iters):
resp = await self.client.messages.create(
model=self.model, max_tokens=2048, system=system,
tools=self.tools, messages=messages,
)
messages.append({"role": "assistant", "content": resp.content})
if resp.stop_reason != "tool_use":
return next(b.text for b in resp.content if b.type == "text")
blocks = [b for b in resp.content if b.type == "tool_use"]
messages.append({"role": "user", "content": await self._dispatch(blocks)})
raise RuntimeError("max_iters reached")