diff --git a/.gitignore b/.gitignore index 95060b8..51510f8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,7 @@ build/ *.egg-info/ .env .pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.venv/ +venv/ diff --git a/README.md b/README.md index cf7fbd8..cac291e 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,72 @@ while True: page = client.messages.list(mailbox_id=mailbox.id, limit=50, cursor=page.next_cursor) ``` +## 프레임워크 통합 (LangChain / CrewAI) + +LoftBox 도구를 LLM 에이전트 프레임워크에 그대로 꽂을 수 있습니다. 노출 도구: +`send_email`, `check_inbox`, `list_messages`, `approve_message`, `reject_message`. + +각 프레임워크는 선택 의존성으로 설치합니다(미설치 시에도 base SDK 는 정상 동작): + +```bash +pip install loftbox[langchain] # LangChain +pip install loftbox[crewai] # CrewAI +``` + +### LangChain + +```python +from loftbox import LoftBox +from loftbox.integrations.langchain import LoftBoxToolkit + +client = LoftBox(api_key="lb_live_xxx") +tools = LoftBoxToolkit(client).get_tools() + +# tools 를 LangChain 에이전트에 전달 +from langchain.agents import create_tool_calling_agent, AgentExecutor +from langchain_core.prompts import ChatPromptTemplate +from langchain_openai import ChatOpenAI + +llm = ChatOpenAI(model="gpt-4o-mini") +prompt = ChatPromptTemplate.from_messages([ + ("system", "너는 이메일을 다루는 에이전트다."), + ("human", "{input}"), + ("placeholder", "{agent_scratchpad}"), +]) +agent = create_tool_calling_agent(llm, tools, prompt) +AgentExecutor(agent=agent, tools=tools).invoke( + {"input": "mb_123 받은편지함에 새 메일 있는지 확인해줘"} +) +``` + +전체 예제: `examples/langchain_agent.py`. + +### CrewAI + +```python +from loftbox import LoftBox +from loftbox.integrations.crewai import get_crewai_tools +from crewai import Agent, Task, Crew + +client = LoftBox(api_key="lb_live_xxx") +tools = get_crewai_tools(client) + +support = Agent( + role="이메일 지원 담당", + goal="받은편지함을 확인하고 답장한다", + backstory="LoftBox 메일박스를 운영하는 에이전트.", + tools=tools, +) +task = Task( + description="mb_123 받은편지함을 확인하고 요약하라", + agent=support, + expected_output="새 메일 요약", +) +Crew(agents=[support], tasks=[task]).kickoff() +``` + +전체 예제: `examples/crewai_agent.py`. + ## 예제 `examples/quickstart.py` 참고. diff --git a/examples/crewai_agent.py b/examples/crewai_agent.py new file mode 100644 index 0000000..845db37 --- /dev/null +++ b/examples/crewai_agent.py @@ -0,0 +1,42 @@ +"""LoftBox + CrewAI 최소 예제. + +실행 전: + pip install loftbox[crewai] + export LOFTBOX_API_KEY=lb_live_xxx + export OPENAI_API_KEY=sk-xxx # CrewAI 기본 LLM +""" + +from __future__ import annotations + +import os + +from loftbox import LoftBox +from loftbox.integrations.crewai import get_crewai_tools + + +def main() -> None: + client = LoftBox(api_key=os.environ["LOFTBOX_API_KEY"]) + tools = get_crewai_tools(client) + + print(f"LoftBox 도구 {len(tools)}개: {[t.name for t in tools]}") + + # 실제 에이전트 연결 예 (선택 — crewai + LLM 필요): + # + # from crewai import Agent, Task, Crew + # + # support = Agent( + # role="이메일 지원 담당", + # goal="받은편지함을 확인하고 답장한다", + # backstory="LoftBox 메일박스를 운영하는 에이전트.", + # tools=tools, + # ) + # task = Task( + # description="mb_123 받은편지함을 확인하고 요약하라", + # agent=support, + # expected_output="새 메일 요약", + # ) + # Crew(agents=[support], tasks=[task]).kickoff() + + +if __name__ == "__main__": + main() diff --git a/examples/langchain_agent.py b/examples/langchain_agent.py new file mode 100644 index 0000000..706d7ed --- /dev/null +++ b/examples/langchain_agent.py @@ -0,0 +1,41 @@ +"""LoftBox + LangChain 최소 예제. + +실행 전: + pip install loftbox[langchain] langchain-openai + export LOFTBOX_API_KEY=lb_live_xxx + export OPENAI_API_KEY=sk-xxx +""" + +from __future__ import annotations + +import os + +from loftbox import LoftBox +from loftbox.integrations.langchain import LoftBoxToolkit + + +def main() -> None: + client = LoftBox(api_key=os.environ["LOFTBOX_API_KEY"]) + tools = LoftBoxToolkit(client).get_tools() + + print(f"LoftBox 도구 {len(tools)}개: {[t.name for t in tools]}") + + # 실제 에이전트 연결 예 (선택 — langchain + LLM 필요): + # + # from langchain.agents import create_tool_calling_agent, AgentExecutor + # from langchain_core.prompts import ChatPromptTemplate + # from langchain_openai import ChatOpenAI + # + # llm = ChatOpenAI(model="gpt-4o-mini") + # prompt = ChatPromptTemplate.from_messages([ + # ("system", "너는 이메일을 다루는 에이전트다."), + # ("human", "{input}"), + # ("placeholder", "{agent_scratchpad}"), + # ]) + # agent = create_tool_calling_agent(llm, tools, prompt) + # executor = AgentExecutor(agent=agent, tools=tools) + # executor.invoke({"input": "mb_123 받은편지함에 새 메일 있는지 확인해줘"}) + + +if __name__ == "__main__": + main() diff --git a/loftbox/__init__.py b/loftbox/__init__.py index 16a12be..ff39cb4 100644 --- a/loftbox/__init__.py +++ b/loftbox/__init__.py @@ -23,7 +23,7 @@ Webhook, ) -__version__ = "0.1.0" +__version__ = "0.2.0" __all__ = [ "LoftBox", # models diff --git a/loftbox/client.py b/loftbox/client.py index 6f65081..af7a061 100644 --- a/loftbox/client.py +++ b/loftbox/client.py @@ -39,7 +39,7 @@ DEFAULT_BASE_URL = "https://api.loftbox.net" DEFAULT_TIMEOUT = 30.0 -USER_AGENT = "loftbox-python/0.1.0" +USER_AGENT = "loftbox-python/0.2.0" class LoftBox: diff --git a/loftbox/integrations/__init__.py b/loftbox/integrations/__init__.py new file mode 100644 index 0000000..f692d25 --- /dev/null +++ b/loftbox/integrations/__init__.py @@ -0,0 +1,17 @@ +"""프레임워크 통합 — LangChain / CrewAI 도구. + +서브모듈은 lazy/guarded import 한다. 해당 프레임워크가 설치돼 있지 않아도 +`import loftbox` 와 base SDK 는 깨지지 않는다. 통합을 쓰려면: + + pip install loftbox[langchain] # LangChain + pip install loftbox[crewai] # CrewAI + +서브모듈 import 시점에 프레임워크 미설치면 친절한 ImportError 를 던진다: + + from loftbox.integrations.langchain import LoftBoxToolkit + from loftbox.integrations.crewai import get_crewai_tools +""" + +from __future__ import annotations + +__all__ = ["langchain", "crewai"] diff --git a/loftbox/integrations/_common.py b/loftbox/integrations/_common.py new file mode 100644 index 0000000..ee48aac --- /dev/null +++ b/loftbox/integrations/_common.py @@ -0,0 +1,168 @@ +"""LangChain / CrewAI 도구가 공유하는 인자 스키마 + 실행 로직. + +도구별로 pydantic args_schema 와 실제 SDK 호출을 한곳에 정의해, 두 프레임워크 +어댑터가 같은 동작을 갖도록 한다. SDK 가 반환하는 모델은 사람이 읽을 수 있는 +문자열로 요약해서 LLM 컨텍스트에 넣는다. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, List, Optional + +from pydantic import BaseModel, Field + +if TYPE_CHECKING: + from ..client import LoftBox + from ..models import Message, Page + + +# -- 인자 스키마 ------------------------------------------------------------ + + +class SendEmailArgs(BaseModel): + """이메일 발송 인자.""" + + mailbox_id: str = Field(description="발송에 사용할 메일박스 ID (mb_...)") + to: List[str] = Field(description="수신자 이메일 주소 목록") + subject: str = Field(description="이메일 제목") + body_text: Optional[str] = Field( + default=None, description="평문 본문. body_html 과 최소 하나는 필요." + ) + body_html: Optional[str] = Field(default=None, description="HTML 본문(선택)") + cc: Optional[List[str]] = Field(default=None, description="참조(cc) 주소 목록(선택)") + in_reply_to: Optional[str] = Field( + default=None, description="답장 시 원본 메시지의 Message-ID(선택)" + ) + + +class CheckInboxArgs(BaseModel): + """미확인 수신 메시지 폴링 인자.""" + + mailbox_id: str = Field(description="조회할 메일박스 ID (mb_...)") + limit: Optional[int] = Field(default=None, description="가져올 최대 개수(선택)") + cursor: Optional[str] = Field(default=None, description="다음 페이지 커서(선택)") + + +class ListMessagesArgs(BaseModel): + """메시지 목록 조회 인자.""" + + mailbox_id: Optional[str] = Field(default=None, description="메일박스 ID 필터(선택)") + direction: Optional[str] = Field( + default=None, description="방향 필터: 'incoming' 또는 'outgoing'(선택)" + ) + status: Optional[str] = Field(default=None, description="상태 필터(선택)") + q: Optional[str] = Field(default=None, description="전문 검색어(선택)") + limit: Optional[int] = Field(default=None, description="최대 개수(선택)") + cursor: Optional[str] = Field(default=None, description="다음 페이지 커서(선택)") + + +class ApproveMessageArgs(BaseModel): + """발송 승인 인자.""" + + message_id: str = Field(description="승인할 메시지 ID (msg_...)") + reason: str = Field(description="승인 사유 (감사 로그에 기록됨)") + + +class RejectMessageArgs(BaseModel): + """발송 거부 인자.""" + + message_id: str = Field(description="거부할 메시지 ID (msg_...)") + reason: str = Field(description="거부 사유 (감사 로그에 기록됨)") + + +# -- 도구 메타 + 실행 ------------------------------------------------------- + +SEND_EMAIL_DESCRIPTION = ( + "LoftBox 메일박스에서 이메일을 발송한다. mailbox_id, 수신자(to), 제목(subject)이 " + "필요하며 body_text 또는 body_html 중 하나 이상을 줘야 한다. 답장이면 in_reply_to 에 " + "원본 Message-ID 를 넣는다." +) +CHECK_INBOX_DESCRIPTION = ( + "메일박스의 미확인(unacked) 수신 메시지를 폴링한다. 새로 도착한 이메일을 확인할 때 쓴다." +) +LIST_MESSAGES_DESCRIPTION = ( + "메시지 목록을 조회한다. mailbox_id/direction/status 로 필터하거나 q 로 전문 검색한다." +) +APPROVE_MESSAGE_DESCRIPTION = "승인 대기 중인 발송 메시지를 승인한다. 사유(reason)가 필요하다." +REJECT_MESSAGE_DESCRIPTION = "승인 대기 중인 발송 메시지를 거부한다. 사유(reason)가 필요하다." + + +def _summarize_message(msg: "Message") -> str: + parts = [f"id={msg.id}"] + if msg.status: + parts.append(f"status={msg.status}") + if msg.subject: + parts.append(f"subject={msg.subject!r}") + return "Message(" + ", ".join(parts) + ")" + + +def _summarize_page(page: "Page") -> str: + if not page.data: + return "메시지 없음." + lines = [_summarize_message(m) for m in page.data] + out = f"{len(page.data)}건:\n" + "\n".join(lines) + if page.next_cursor: + out += f"\nnext_cursor={page.next_cursor}" + return out + + +def run_send_email( + client: "LoftBox", + mailbox_id: str, + to: List[str], + subject: str, + body_text: Optional[str] = None, + body_html: Optional[str] = None, + cc: Optional[List[str]] = None, + in_reply_to: Optional[str] = None, +) -> str: + msg = client.messages.send( + mailbox_id=mailbox_id, + to=to, + subject=subject, + body_text=body_text, + body_html=body_html, + cc=cc, + in_reply_to=in_reply_to, + ) + return "발송됨: " + _summarize_message(msg) + + +def run_check_inbox( + client: "LoftBox", + mailbox_id: str, + limit: Optional[int] = None, + cursor: Optional[str] = None, +) -> str: + page = client.mailboxes.list_inbox(mailbox_id, limit=limit, cursor=cursor) + return _summarize_page(page) + + +def run_list_messages( + client: "LoftBox", + mailbox_id: Optional[str] = None, + direction: Optional[str] = None, + status: Optional[str] = None, + q: Optional[str] = None, + limit: Optional[int] = None, + cursor: Optional[str] = None, +) -> str: + page = client.messages.list( + mailbox_id=mailbox_id, + direction=direction, + status=status, + q=q, + limit=limit, + cursor=cursor, + ) + return _summarize_page(page) + + +def run_approve_message(client: "LoftBox", message_id: str, reason: str) -> str: + msg = client.messages.approve(message_id, reason) + return "승인됨: " + _summarize_message(msg) + + +def run_reject_message(client: "LoftBox", message_id: str, reason: str) -> str: + msg = client.messages.reject(message_id, reason) + return "거부됨: " + _summarize_message(msg) diff --git a/loftbox/integrations/crewai.py b/loftbox/integrations/crewai.py new file mode 100644 index 0000000..8c39e58 --- /dev/null +++ b/loftbox/integrations/crewai.py @@ -0,0 +1,148 @@ +"""LoftBox 도구의 CrewAI 통합. + +crewai 의 ``BaseTool`` 로 SDK 메서드를 노출한다. 사용: + + from loftbox import LoftBox + from loftbox.integrations.crewai import get_crewai_tools + + client = LoftBox(api_key="lb_live_xxx") + tools = get_crewai_tools(client) + # tools 를 CrewAI Agent(tools=tools) 에 전달 + +crewai 가 설치돼 있지 않으면 import 시 친절한 ImportError 를 던진다. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, List, Optional, Type + +from pydantic import BaseModel, PrivateAttr + +try: + from crewai.tools import BaseTool +except ImportError as exc: # pragma: no cover - import-guard + raise ImportError( + "CrewAI 통합을 쓰려면 crewai 가 필요합니다. 설치: pip install loftbox[crewai]" + ) from exc + +from . import _common + +if TYPE_CHECKING: + from ..client import LoftBox + + +class _LoftBoxBaseTool(BaseTool): + """LoftBox 클라이언트를 들고 있는 CrewAI 도구 베이스. + + crewai ``BaseTool`` 은 pydantic 모델이라, 클라이언트는 PrivateAttr 로 저장한다. + """ + + _client: "LoftBox" = PrivateAttr() + + def __init__(self, client: "LoftBox", **kwargs: object) -> None: + super().__init__(**kwargs) + self._client = client + + +class SendEmailTool(_LoftBoxBaseTool): + name: str = "send_email" + description: str = _common.SEND_EMAIL_DESCRIPTION + args_schema: Type[BaseModel] = _common.SendEmailArgs + + def _run( + self, + mailbox_id: str, + to: List[str], + subject: str, + body_text: Optional[str] = None, + body_html: Optional[str] = None, + cc: Optional[List[str]] = None, + in_reply_to: Optional[str] = None, + ) -> str: + return _common.run_send_email( + self._client, + mailbox_id=mailbox_id, + to=to, + subject=subject, + body_text=body_text, + body_html=body_html, + cc=cc, + in_reply_to=in_reply_to, + ) + + +class CheckInboxTool(_LoftBoxBaseTool): + name: str = "check_inbox" + description: str = _common.CHECK_INBOX_DESCRIPTION + args_schema: Type[BaseModel] = _common.CheckInboxArgs + + def _run( + self, mailbox_id: str, limit: Optional[int] = None, cursor: Optional[str] = None + ) -> str: + return _common.run_check_inbox( + self._client, mailbox_id=mailbox_id, limit=limit, cursor=cursor + ) + + +class ListMessagesTool(_LoftBoxBaseTool): + name: str = "list_messages" + description: str = _common.LIST_MESSAGES_DESCRIPTION + args_schema: Type[BaseModel] = _common.ListMessagesArgs + + def _run( + self, + mailbox_id: Optional[str] = None, + direction: Optional[str] = None, + status: Optional[str] = None, + q: Optional[str] = None, + limit: Optional[int] = None, + cursor: Optional[str] = None, + ) -> str: + return _common.run_list_messages( + self._client, + mailbox_id=mailbox_id, + direction=direction, + status=status, + q=q, + limit=limit, + cursor=cursor, + ) + + +class ApproveMessageTool(_LoftBoxBaseTool): + name: str = "approve_message" + description: str = _common.APPROVE_MESSAGE_DESCRIPTION + args_schema: Type[BaseModel] = _common.ApproveMessageArgs + + def _run(self, message_id: str, reason: str) -> str: + return _common.run_approve_message(self._client, message_id=message_id, reason=reason) + + +class RejectMessageTool(_LoftBoxBaseTool): + name: str = "reject_message" + description: str = _common.REJECT_MESSAGE_DESCRIPTION + args_schema: Type[BaseModel] = _common.RejectMessageArgs + + def _run(self, message_id: str, reason: str) -> str: + return _common.run_reject_message(self._client, message_id=message_id, reason=reason) + + +def get_crewai_tools(client: "LoftBox") -> List[BaseTool]: + """CrewAI Agent 에 넘길 LoftBox 도구 목록.""" + return [ + SendEmailTool(client), + CheckInboxTool(client), + ListMessagesTool(client), + ApproveMessageTool(client), + RejectMessageTool(client), + ] + + +__all__ = [ + "get_crewai_tools", + "SendEmailTool", + "CheckInboxTool", + "ListMessagesTool", + "ApproveMessageTool", + "RejectMessageTool", +] diff --git a/loftbox/integrations/langchain.py b/loftbox/integrations/langchain.py new file mode 100644 index 0000000..647e157 --- /dev/null +++ b/loftbox/integrations/langchain.py @@ -0,0 +1,80 @@ +"""LoftBox 도구의 LangChain 통합. + +langchain_core 의 StructuredTool 로 SDK 메서드를 노출한다. 사용: + + from loftbox import LoftBox + from loftbox.integrations.langchain import LoftBoxToolkit + + client = LoftBox(api_key="lb_live_xxx") + tools = LoftBoxToolkit(client).get_tools() + # tools 를 LangChain 에이전트에 그대로 전달 + +langchain_core 가 설치돼 있지 않으면 import 시 친절한 ImportError 를 던진다. +""" + +from __future__ import annotations + +from functools import partial +from typing import TYPE_CHECKING, List + +try: + from langchain_core.tools import StructuredTool +except ImportError as exc: # pragma: no cover - import-guard + raise ImportError( + "LangChain 통합을 쓰려면 langchain-core 가 필요합니다. 설치: pip install loftbox[langchain]" + ) from exc + +from . import _common + +if TYPE_CHECKING: + from ..client import LoftBox + + +class LoftBoxToolkit: + """LoftBox 클라이언트를 LangChain 도구 묶음으로 변환한다. + + Args: + client: 인증된 ``LoftBox`` 클라이언트. + """ + + def __init__(self, client: "LoftBox") -> None: + self._client = client + + def get_tools(self) -> List["StructuredTool"]: + """LangChain 에이전트에 넘길 ``StructuredTool`` 목록.""" + c = self._client + return [ + StructuredTool.from_function( + func=partial(_common.run_send_email, c), + name="send_email", + description=_common.SEND_EMAIL_DESCRIPTION, + args_schema=_common.SendEmailArgs, + ), + StructuredTool.from_function( + func=partial(_common.run_check_inbox, c), + name="check_inbox", + description=_common.CHECK_INBOX_DESCRIPTION, + args_schema=_common.CheckInboxArgs, + ), + StructuredTool.from_function( + func=partial(_common.run_list_messages, c), + name="list_messages", + description=_common.LIST_MESSAGES_DESCRIPTION, + args_schema=_common.ListMessagesArgs, + ), + StructuredTool.from_function( + func=partial(_common.run_approve_message, c), + name="approve_message", + description=_common.APPROVE_MESSAGE_DESCRIPTION, + args_schema=_common.ApproveMessageArgs, + ), + StructuredTool.from_function( + func=partial(_common.run_reject_message, c), + name="reject_message", + description=_common.REJECT_MESSAGE_DESCRIPTION, + args_schema=_common.RejectMessageArgs, + ), + ] + + +__all__ = ["LoftBoxToolkit"] diff --git a/pyproject.toml b/pyproject.toml index 85f18c3..5a4188c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "loftbox" -version = "0.1.0" +version = "0.2.0" description = "LoftBox Python SDK - Email infrastructure for AI agents" readme = "README.md" requires-python = ">=3.9" @@ -17,8 +17,11 @@ Homepage = "https://loftbox.net" Repository = "https://github.com/TheMagicTower/loftbox-sdk-python" [project.optional-dependencies] -# 프레임워크 통합(LangChain/CrewAI)은 구현 후 추가 — 미구현 광고 금지. -dev = ["pytest>=7", "respx>=0.20", "mypy>=1.8", "ruff>=0.4"] +langchain = ["langchain-core>=0.1"] +crewai = ["crewai>=0.1"] +# dev 에는 langchain-core 만 포함(테스트용). crewai 는 의존성이 무거워 제외 — +# crewai 통합 테스트는 미설치 시 importorskip 으로 skip 된다. +dev = ["pytest>=7", "respx>=0.20", "mypy>=1.8", "ruff>=0.4", "langchain-core>=0.1"] [build-system] requires = ["hatchling"] diff --git a/tests/test_integrations.py b/tests/test_integrations.py new file mode 100644 index 0000000..47e26d8 --- /dev/null +++ b/tests/test_integrations.py @@ -0,0 +1,191 @@ +"""프레임워크 통합 테스트. + +langchain_core / crewai 가 설치된 경우에만 해당 블록을 실행하고(importorskip), +미설치 시 친절한 ImportError 메시지를 던지는지도 검증한다. SDK 호출은 Mock +client 로 가로채 올바른 인자 전달을 확인한다. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from loftbox.models import Message, Page + + +def _mock_client() -> MagicMock: + """messages/mailboxes 네임스페이스를 가진 LoftBox 대역.""" + client = MagicMock() + client.messages.send.return_value = Message(id="msg_1", status="queued", subject="hi") + client.messages.approve.return_value = Message(id="msg_1", status="approved") + client.messages.reject.return_value = Message(id="msg_1", status="rejected") + client.messages.list.return_value = Page(data=[Message(id="m1")], next_cursor="c2") + client.mailboxes.list_inbox.return_value = Page(data=[Message(id="in_1")], next_cursor=None) + return client + + +# -- LangChain -------------------------------------------------------------- + + +def test_langchain_toolkit_builds_tools() -> None: + pytest.importorskip("langchain_core") + from loftbox.integrations.langchain import LoftBoxToolkit + + client = _mock_client() + tools = LoftBoxToolkit(client).get_tools() + names = {t.name for t in tools} + assert names == { + "send_email", + "check_inbox", + "list_messages", + "approve_message", + "reject_message", + } + + +def test_langchain_send_email_invokes_sdk() -> None: + pytest.importorskip("langchain_core") + from loftbox.integrations.langchain import LoftBoxToolkit + + client = _mock_client() + tools = {t.name: t for t in LoftBoxToolkit(client).get_tools()} + + out = tools["send_email"].invoke( + { + "mailbox_id": "mb_1", + "to": ["a@example.com"], + "subject": "hi", + "body_text": "world", + } + ) + client.messages.send.assert_called_once_with( + mailbox_id="mb_1", + to=["a@example.com"], + subject="hi", + body_text="world", + body_html=None, + cc=None, + in_reply_to=None, + ) + assert "msg_1" in out + + +def test_langchain_check_inbox_and_approve_reject() -> None: + pytest.importorskip("langchain_core") + from loftbox.integrations.langchain import LoftBoxToolkit + + client = _mock_client() + tools = {t.name: t for t in LoftBoxToolkit(client).get_tools()} + + tools["check_inbox"].invoke({"mailbox_id": "mb_1", "limit": 5}) + client.mailboxes.list_inbox.assert_called_once_with("mb_1", limit=5, cursor=None) + + tools["approve_message"].invoke({"message_id": "msg_1", "reason": "ok"}) + client.messages.approve.assert_called_once_with("msg_1", "ok") + + tools["reject_message"].invoke({"message_id": "msg_1", "reason": "spam"}) + client.messages.reject.assert_called_once_with("msg_1", "spam") + + +def test_langchain_list_messages_passes_filters() -> None: + pytest.importorskip("langchain_core") + from loftbox.integrations.langchain import LoftBoxToolkit + + client = _mock_client() + tools = {t.name: t for t in LoftBoxToolkit(client).get_tools()} + + out = tools["list_messages"].invoke({"mailbox_id": "mb_1", "direction": "inbound", "q": "x"}) + client.messages.list.assert_called_once_with( + mailbox_id="mb_1", + direction="inbound", + status=None, + q="x", + limit=None, + cursor=None, + ) + assert "m1" in out + + +# -- CrewAI ----------------------------------------------------------------- + + +def test_crewai_tools_build_and_invoke() -> None: + pytest.importorskip("crewai") + from loftbox.integrations.crewai import get_crewai_tools + + client = _mock_client() + tools = {t.name: t for t in get_crewai_tools(client)} + assert set(tools) == { + "send_email", + "check_inbox", + "list_messages", + "approve_message", + "reject_message", + } + + out = tools["send_email"]._run( + mailbox_id="mb_1", to=["a@example.com"], subject="hi", body_text="world" + ) + client.messages.send.assert_called_once_with( + mailbox_id="mb_1", + to=["a@example.com"], + subject="hi", + body_text="world", + body_html=None, + cc=None, + in_reply_to=None, + ) + assert "msg_1" in out + + tools["approve_message"]._run(message_id="msg_1", reason="ok") + client.messages.approve.assert_called_once_with("msg_1", "ok") + + +# -- import guard 메시지 ---------------------------------------------------- + + +def test_langchain_import_guard_message(monkeypatch: pytest.MonkeyPatch) -> None: + """langchain_core 미설치 상황을 흉내내 친절한 ImportError 를 확인.""" + import builtins + import importlib + import sys + + real_import = builtins.__import__ + + def fake_import(name: str, *args: object, **kwargs: object) -> object: + if name == "langchain_core" or name.startswith("langchain_core."): + raise ImportError("No module named 'langchain_core'") + return real_import(name, *args, **kwargs) + + monkeypatch.delitem(sys.modules, "loftbox.integrations.langchain", raising=False) + monkeypatch.setattr(builtins, "__import__", fake_import) + with pytest.raises(ImportError, match=r"pip install loftbox\[langchain\]"): + importlib.import_module("loftbox.integrations.langchain") + + +def test_crewai_import_guard_message(monkeypatch: pytest.MonkeyPatch) -> None: + """crewai 미설치 상황을 흉내내 친절한 ImportError 를 확인.""" + import builtins + import importlib + import sys + + real_import = builtins.__import__ + + def fake_import(name: str, *args: object, **kwargs: object) -> object: + if name == "crewai" or name.startswith("crewai."): + raise ImportError("No module named 'crewai'") + return real_import(name, *args, **kwargs) + + monkeypatch.delitem(sys.modules, "loftbox.integrations.crewai", raising=False) + monkeypatch.setattr(builtins, "__import__", fake_import) + with pytest.raises(ImportError, match=r"pip install loftbox\[crewai\]"): + importlib.import_module("loftbox.integrations.crewai") + + +def test_base_sdk_imports_without_frameworks() -> None: + """프레임워크 없이도 base SDK 가 동작한다(guarded import).""" + import loftbox + + client = loftbox.LoftBox(api_key="x") + assert client.api_key == "x"