diff --git a/docs/docs.json b/docs/docs.json index 865947d913..a54767fd7d 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -306,7 +306,8 @@ "edge/en/tools/ai-ml/ragtool", "edge/en/tools/ai-ml/codeinterpretertool", "edge/en/tools/ai-ml/daytona", - "edge/en/tools/ai-ml/e2bsandboxtools" + "edge/en/tools/ai-ml/e2bsandboxtools", + "edge/en/tools/ai-ml/spritestool" ] }, { @@ -14761,7 +14762,8 @@ "edge/pt-BR/tools/ai-ml/langchaintool", "edge/pt-BR/tools/ai-ml/ragtool", "edge/pt-BR/tools/ai-ml/codeinterpretertool", - "edge/pt-BR/tools/ai-ml/daytona" + "edge/pt-BR/tools/ai-ml/daytona", + "edge/pt-BR/tools/ai-ml/spritestool" ] }, { @@ -28217,7 +28219,8 @@ "edge/ko/tools/ai-ml/llamaindextool", "edge/ko/tools/ai-ml/langchaintool", "edge/ko/tools/ai-ml/ragtool", - "edge/ko/tools/ai-ml/codeinterpretertool" + "edge/ko/tools/ai-ml/codeinterpretertool", + "edge/ko/tools/ai-ml/spritestool" ] }, { @@ -42118,7 +42121,8 @@ "edge/ar/tools/ai-ml/llamaindextool", "edge/ar/tools/ai-ml/langchaintool", "edge/ar/tools/ai-ml/ragtool", - "edge/ar/tools/ai-ml/codeinterpretertool" + "edge/ar/tools/ai-ml/codeinterpretertool", + "edge/ar/tools/ai-ml/spritestool" ] }, { diff --git a/docs/edge/ar/tools/ai-ml/spritestool.mdx b/docs/edge/ar/tools/ai-ml/spritestool.mdx new file mode 100644 index 0000000000..133f45ba5b --- /dev/null +++ b/docs/edge/ar/tools/ai-ml/spritestool.mdx @@ -0,0 +1,65 @@ +--- +title: أداة تنفيذ Fly.io Sprites +description: امنح وكلاء CrewAI وصولاً إلى shell داخل Fly.io Sprite موجود ودائم. +icon: terminal +mode: "wide" +--- + +## نظرة عامة + +تنفّذ `SpritesExecTool` أوامر shell داخل [Fly.io Sprite](https://sprites.dev) موجود، باستخدام SDK الرسمي لـ Python. تبقى الملفات بين الاستدعاءات. يبدأ كل استدعاء جلسة Bash جديدة، لذلك لا تنتقل متغيرات shell وتغييرات مجلد العمل إلى الاستدعاء التالي. + +يدير المستدعي دورة حياة Sprite: لا تنشئ هذه الأداة Sprites ولا تحذفها. + +## التثبيت والمصادقة + +ثبّت الاعتماد الاختياري: + +```shell +uv add "crewai-tools[sprites]" +``` + +أنشئ Sprite باستخدام [Sprites CLI](https://docs.sprites.dev/cli/) قبل استخدام الأداة. اضبط `SPRITE_TOKEN` في بيئة عملية CrewAI على رمز مخوّل للوصول إلى ذلك Sprite، أو مرّره عبر `api_key` عند إنشاء الأداة. الرمز ليس وسيطاً للوكيل، ويُستبعد من تسلسل الأداة، ولا يُحقن في الأوامر البعيدة. + +## الاستخدام + +```python +from crewai import Agent, Crew, Task +from crewai_tools import SpritesExecTool + +tool = SpritesExecTool(sprite_name="crew-workspace", timeout=60) +agent = Agent( + role="Python developer", + goal="Run and verify Python programs in the provided Fly.io Sprite", + backstory="You check program output and report failures accurately.", + tools=[tool], +) +task = Task( + description="Use the Sprite to print the sum of the integers from 1 to 100.", + expected_output="The command used, its exit code, and the computed sum.", + agent=agent, +) +crew = Crew(agents=[agent], tasks=[task]) +result = crew.kickoff() +``` + +يتطلب المثال أيضاً بيانات اعتماد موفّر LLM الذي تختاره. للاستدعاء المباشر دون LLM: + +```python +result = tool.run(command="python -c 'print(sum(range(1, 101)))'") +# {"exit_code": 0, "stdout": "5050\n", "stderr": "", +# "stdout_truncated": False, "stderr_truncated": False} +``` + +## الإعدادات والوسائط + +اضبط `sprite_name` (مطلوب)، و`api_key` (يستخدم `SPRITE_TOKEN` افتراضياً)، و`timeout` (60 ثانية افتراضياً، أكبر من 0 وبحد أقصى 300)، و`max_output_chars` (20,000 لكل تدفق افتراضياً) في الأداة. يقدّم الوكيل `command` و`cwd` الاختياري فقط، ويُفسّران داخل Sprite. + +تتضمن النتائج `exit_code` و`stdout` و`stderr` بترميز UTF-8 (تُستبدل البايتات غير الصالحة)، ومؤشر اقتطاع لكل تدفق. تُعاد رموز الخروج غير الصفرية إلى الوكيل ولا تُعامل كأخطاء نقل. تستخدم الاستدعاءات غير المتزامنة `await tool.arun(command="...")`. لا تُخزّن النتائج مؤقتاً افتراضياً لأن الأوامر قد تغيّر الحالة الدائمة. + +## السلامة والحدود + +- تتمتع الأوامر بوصول إلى shell ويمكنها تعديل البيانات أو حذفها والوصول إلى شبكة Sprite وبيانات اعتماده. استخدم Sprite مخصصاً يحتوي فقط على البيانات والصلاحيات التي يحتاجها الوكيل. تثبيت اسم Sprite ليس حداً للتفويض على الخادم. +- يحدّد `timeout` مدة انتظار العميل؛ لكنه **لا** يضمن إنهاء العملية البعيدة. يغلق الإلغاء غير المتزامن الاتصال المحلي دون ترك خيط عامل في منفّذ المهام قيد التشغيل، لكنه لا يضمن أيضاً إنهاء العملية البعيدة. بعد الإلغاء أو انتهاء المهلة أو فشل الاتصال، افحص Sprite قبل إعادة أمر له آثار جانبية. +- يحتفظ جامع الخرج بحد أقصى `max_output_chars` حرفاً لكل تدفق، مع فك ترميز UTF-8 تدريجياً. يُتخلّص من الخرج الزائد قبل تراكمه في SDK، مع مواصلة قراءة الاتصال حتى خروج الأمر أو انتهاء المهلة. يحدّ ذلك من الخرج المحتفظ به، وليس من حركة الشبكة أو أعباء النقل لكل إطار. وجّه الخرج المطوّل إلى ملفات داخل Sprite. +- لا تُمرّر متغيرات بيئة المضيف تلقائياً. تبقى الملفات إلى أن تديرها أو تزيلها بنفسك. diff --git a/docs/edge/en/tools/ai-ml/spritestool.mdx b/docs/edge/en/tools/ai-ml/spritestool.mdx new file mode 100644 index 0000000000..d0faad9c24 --- /dev/null +++ b/docs/edge/en/tools/ai-ml/spritestool.mdx @@ -0,0 +1,65 @@ +--- +title: Fly.io Sprites Exec Tool +description: Give CrewAI agents shell access to an existing, persistent Fly.io Sprite. +icon: terminal +mode: "wide" +--- + +## Overview + +`SpritesExecTool` runs shell commands in an existing [Fly.io Sprite](https://sprites.dev), using the official Python SDK. Files persist across calls. Each call starts a new Bash shell, so shell variables and working-directory changes do not carry over. + +The caller manages the Sprite lifecycle: this tool neither creates nor deletes Sprites. + +## Installation and authentication + +Install the optional dependency: + +```shell +uv add "crewai-tools[sprites]" +``` + +Create a Sprite using the [Sprites CLI](https://docs.sprites.dev/cli/) before using the tool. Set `SPRITE_TOKEN` in the CrewAI process environment to a token authorized for that Sprite, or pass it as `api_key` when constructing the tool. The token is not an agent argument, is excluded from tool serialization, and is not injected into remote commands. + +## Usage + +```python +from crewai import Agent, Crew, Task +from crewai_tools import SpritesExecTool + +tool = SpritesExecTool(sprite_name="crew-workspace", timeout=60) +agent = Agent( + role="Python developer", + goal="Run and verify Python programs in the provided Fly.io Sprite", + backstory="You check program output and report failures accurately.", + tools=[tool], +) +task = Task( + description="Use the Sprite to print the sum of the integers from 1 to 100.", + expected_output="The command used, its exit code, and the computed sum.", + agent=agent, +) +crew = Crew(agents=[agent], tasks=[task]) +result = crew.kickoff() +``` + +The example also requires your chosen LLM provider's credentials. For a direct call without an LLM: + +```python +result = tool.run(command="python -c 'print(sum(range(1, 101)))'") +# {"exit_code": 0, "stdout": "5050\n", "stderr": "", +# "stdout_truncated": False, "stderr_truncated": False} +``` + +## Configuration and arguments + +Configure `sprite_name` (required), `api_key` (defaults to `SPRITE_TOKEN`), `timeout` (default 60 seconds, greater than 0 and at most 300), and `max_output_chars` (default 20,000 per stream) on the tool. The agent only supplies `command` and optional `cwd`, both interpreted inside the Sprite. + +Results include `exit_code`, UTF-8 `stdout` and `stderr` (invalid bytes are replaced), and a truncation flag for each stream. Non-zero exits are returned to the agent, not treated as transport failures. Async calls use `await tool.arun(command="...")`. Results are not cached by default because commands can change persistent state. + +## Safety and limits + +- Commands have shell access and can modify or delete data and access the Sprite's network and credentials. Use a dedicated Sprite with only the data and permissions the agent needs. Binding a Sprite name is not a server-side authorization boundary. +- `timeout` limits how long the client waits; it does **not** guarantee remote process termination. Async cancellation closes the local connection without leaving an executor worker running, but also does not guarantee remote termination. After cancellation, a timeout, or a connection failure, inspect the Sprite before retrying a command with side effects. +- Output collection retains at most `max_output_chars` characters per stream, decoding UTF-8 incrementally. Excess output is discarded before SDK accumulation while the connection is drained until command exit or timeout. This bounds retained output, not network traffic or per-frame transport overhead. Redirect verbose output to files in the Sprite. +- No host environment variables are forwarded automatically. Files remain until you manage or remove them yourself. diff --git a/docs/edge/ko/tools/ai-ml/spritestool.mdx b/docs/edge/ko/tools/ai-ml/spritestool.mdx new file mode 100644 index 0000000000..61a8138d0d --- /dev/null +++ b/docs/edge/ko/tools/ai-ml/spritestool.mdx @@ -0,0 +1,65 @@ +--- +title: Fly.io Sprites 실행 도구 +description: CrewAI 에이전트에 기존의 영구 Fly.io Sprite 셸 접근 권한을 제공합니다. +icon: terminal +mode: "wide" +--- + +## 개요 + +`SpritesExecTool`은 공식 Python SDK를 사용하여 기존 [Fly.io Sprite](https://sprites.dev)에서 셸 명령을 실행합니다. 파일은 호출 간에 유지됩니다. 각 호출은 새로운 Bash 셸을 시작하므로 셸 변수와 작업 디렉터리 변경 사항은 다음 호출로 이어지지 않습니다. + +Sprite의 수명 주기는 호출자가 관리합니다. 이 도구는 Sprite를 생성하거나 삭제하지 않습니다. + +## 설치 및 인증 + +선택적 의존성을 설치하세요. + +```shell +uv add "crewai-tools[sprites]" +``` + +도구를 사용하기 전에 [Sprites CLI](https://docs.sprites.dev/cli/)로 Sprite를 생성하세요. CrewAI 프로세스 환경의 `SPRITE_TOKEN`에 해당 Sprite에 접근할 수 있는 토큰을 설정하거나, 도구를 생성할 때 `api_key`로 전달하세요. 토큰은 에이전트 인수가 아니며, 도구 직렬화에서 제외되고 원격 명령에 주입되지 않습니다. + +## 사용법 + +```python +from crewai import Agent, Crew, Task +from crewai_tools import SpritesExecTool + +tool = SpritesExecTool(sprite_name="crew-workspace", timeout=60) +agent = Agent( + role="Python developer", + goal="Run and verify Python programs in the provided Fly.io Sprite", + backstory="You check program output and report failures accurately.", + tools=[tool], +) +task = Task( + description="Use the Sprite to print the sum of the integers from 1 to 100.", + expected_output="The command used, its exit code, and the computed sum.", + agent=agent, +) +crew = Crew(agents=[agent], tasks=[task]) +result = crew.kickoff() +``` + +이 예제에는 선택한 LLM 제공업체의 인증 정보도 필요합니다. LLM 없이 직접 호출하려면 다음과 같이 사용하세요. + +```python +result = tool.run(command="python -c 'print(sum(range(1, 101)))'") +# {"exit_code": 0, "stdout": "5050\n", "stderr": "", +# "stdout_truncated": False, "stderr_truncated": False} +``` + +## 설정 및 인수 + +도구에서 `sprite_name`(필수), `api_key`(기본값은 `SPRITE_TOKEN`), `timeout`(기본값 60초, 0보다 크고 최대 300), `max_output_chars`(스트림당 기본값 20,000)를 설정하세요. 에이전트는 `command`와 선택적 `cwd`만 제공하며, 둘 다 Sprite 내부에서 해석됩니다. + +결과에는 `exit_code`, UTF-8 `stdout` 및 `stderr`(잘못된 바이트는 대체됨), 각 스트림의 잘림 여부가 포함됩니다. 0이 아닌 종료 코드는 전송 실패로 처리하지 않고 에이전트에 반환합니다. 비동기 호출은 `await tool.arun(command="...")`을 사용합니다. 명령이 영구 상태를 변경할 수 있으므로 기본적으로 결과를 캐시하지 않습니다. + +## 안전 및 제한 사항 + +- 명령은 셸 접근 권한을 가지며 데이터를 수정하거나 삭제하고 Sprite의 네트워크 및 인증 정보에 접근할 수 있습니다. 에이전트에 필요한 데이터와 권한만 있는 전용 Sprite를 사용하세요. Sprite 이름을 고정하는 것은 서버 측 권한 경계가 아닙니다. +- `timeout`은 클라이언트의 대기 시간만 제한하며 원격 프로세스 종료를 **보장하지 않습니다**. 비동기 취소는 실행 중인 executor 작업 스레드를 남기지 않고 로컬 연결을 닫지만 원격 프로세스 종료를 보장하지는 않습니다. 취소, 시간 초과 또는 연결 실패 후에는 부수 효과가 있는 명령을 다시 실행하기 전에 Sprite를 확인하세요. +- 출력 수집기는 UTF-8을 점진적으로 디코딩하며 스트림당 최대 `max_output_chars`개의 문자만 보관합니다. 초과 출력은 SDK에 누적되기 전에 버리지만, 명령이 종료되거나 시간이 초과될 때까지 연결에서 계속 읽습니다. 이는 보관되는 출력을 제한하며 네트워크 트래픽이나 프레임별 전송 오버헤드는 제한하지 않습니다. 많은 출력은 Sprite 내부 파일로 리디렉션하세요. +- 호스트 환경 변수는 자동 전달되지 않습니다. 파일은 직접 관리하거나 제거할 때까지 유지됩니다. diff --git a/docs/edge/pt-BR/tools/ai-ml/spritestool.mdx b/docs/edge/pt-BR/tools/ai-ml/spritestool.mdx new file mode 100644 index 0000000000..b0e04a4bf0 --- /dev/null +++ b/docs/edge/pt-BR/tools/ai-ml/spritestool.mdx @@ -0,0 +1,65 @@ +--- +title: Ferramenta de execução do Fly.io Sprites +description: Dê aos agentes CrewAI acesso ao shell de um Fly.io Sprite existente e persistente. +icon: terminal +mode: "wide" +--- + +## Visão geral + +`SpritesExecTool` executa comandos de shell em um [Fly.io Sprite](https://sprites.dev) existente usando o SDK oficial de Python. Os arquivos persistem entre chamadas. Cada chamada inicia um novo shell Bash, portanto variáveis de shell e alterações no diretório de trabalho não são mantidas. + +O chamador gerencia o ciclo de vida do Sprite: esta ferramenta não cria nem exclui Sprites. + +## Instalação e autenticação + +Instale a dependência opcional: + +```shell +uv add "crewai-tools[sprites]" +``` + +Crie um Sprite usando a [CLI do Sprites](https://docs.sprites.dev/cli/) antes de usar a ferramenta. Defina `SPRITE_TOKEN` no ambiente do processo CrewAI com um token autorizado para esse Sprite ou passe-o como `api_key` ao construir a ferramenta. O token não é um argumento do agente, é excluído da serialização da ferramenta e não é injetado nos comandos remotos. + +## Uso + +```python +from crewai import Agent, Crew, Task +from crewai_tools import SpritesExecTool + +tool = SpritesExecTool(sprite_name="crew-workspace", timeout=60) +agent = Agent( + role="Python developer", + goal="Run and verify Python programs in the provided Fly.io Sprite", + backstory="You check program output and report failures accurately.", + tools=[tool], +) +task = Task( + description="Use the Sprite to print the sum of the integers from 1 to 100.", + expected_output="The command used, its exit code, and the computed sum.", + agent=agent, +) +crew = Crew(agents=[agent], tasks=[task]) +result = crew.kickoff() +``` + +O exemplo também requer as credenciais do provedor de LLM escolhido. Para uma chamada direta sem LLM: + +```python +result = tool.run(command="python -c 'print(sum(range(1, 101)))'") +# {"exit_code": 0, "stdout": "5050\n", "stderr": "", +# "stdout_truncated": False, "stderr_truncated": False} +``` + +## Configuração e argumentos + +Configure `sprite_name` (obrigatório), `api_key` (usa `SPRITE_TOKEN` por padrão), `timeout` (padrão de 60 segundos, maior que 0 e no máximo 300) e `max_output_chars` (padrão de 20.000 por fluxo) na ferramenta. O agente fornece apenas `command` e o `cwd` opcional, ambos interpretados dentro do Sprite. + +Os resultados incluem `exit_code`, `stdout` e `stderr` em UTF-8 (bytes inválidos são substituídos) e um indicador de truncamento para cada fluxo. Códigos de saída diferentes de zero são retornados ao agente, não tratados como falhas de transporte. Chamadas assíncronas usam `await tool.arun(command="...")`. Por padrão, os resultados não são armazenados em cache porque os comandos podem alterar o estado persistente. + +## Segurança e limites + +- Os comandos têm acesso ao shell e podem modificar ou excluir dados e acessar a rede e as credenciais do Sprite. Use um Sprite dedicado com apenas os dados e permissões necessários ao agente. Fixar um nome de Sprite não é um limite de autorização no servidor. +- `timeout` limita o tempo de espera do cliente; **não** garante o encerramento do processo remoto. O cancelamento assíncrono fecha a conexão local sem deixar uma thread do executor em execução, mas também não garante o encerramento remoto. Após um cancelamento, timeout ou falha de conexão, inspecione o Sprite antes de repetir um comando com efeitos colaterais. +- A coleta de saída retém no máximo `max_output_chars` caracteres por fluxo, decodificando UTF-8 incrementalmente. A saída excedente é descartada antes de se acumular no SDK, enquanto a conexão continua sendo lida até o comando terminar ou ocorrer um timeout. Isso limita a saída retida, não o tráfego de rede nem a sobrecarga de transporte por frame. Redirecione saídas extensas para arquivos no Sprite. +- Nenhuma variável de ambiente do host é encaminhada automaticamente. Os arquivos permanecem até que você os gerencie ou remova. diff --git a/lib/crewai-tools/pyproject.toml b/lib/crewai-tools/pyproject.toml index 4b36d30824..339dfc019f 100644 --- a/lib/crewai-tools/pyproject.toml +++ b/lib/crewai-tools/pyproject.toml @@ -26,6 +26,9 @@ Documentation = "https://docs.crewai.com" [project.optional-dependencies] +sprites = [ + "sprites-py>=0.6.0,<0.7", +] scrapfly-sdk = [ "scrapfly-sdk>=0.8.19", ] diff --git a/lib/crewai-tools/src/crewai_tools/__init__.py b/lib/crewai-tools/src/crewai_tools/__init__.py index bb8a946c8a..067221ac20 100644 --- a/lib/crewai-tools/src/crewai_tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/__init__.py @@ -197,6 +197,7 @@ SnowflakeSearchTool, ) from crewai_tools.tools.spider_tool.spider_tool import SpiderTool +from crewai_tools.tools.sprites_tool import SpritesExecTool from crewai_tools.tools.stagehand_tool.stagehand_tool import StagehandTool from crewai_tools.tools.tavily_extractor_tool.tavily_extractor_tool import ( TavilyExtractorTool, @@ -322,6 +323,7 @@ "SnowflakeConfig", "SnowflakeSearchTool", "SpiderTool", + "SpritesExecTool", "StagehandTool", "TXTSearchTool", "TavilyExtractorTool", diff --git a/lib/crewai-tools/src/crewai_tools/tools/__init__.py b/lib/crewai-tools/src/crewai_tools/tools/__init__.py index 2653490f76..60d216a344 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/tools/__init__.py @@ -184,6 +184,7 @@ SnowflakeSearchToolInput, ) from crewai_tools.tools.spider_tool.spider_tool import SpiderTool +from crewai_tools.tools.sprites_tool import SpritesExecTool from crewai_tools.tools.stagehand_tool.stagehand_tool import StagehandTool from crewai_tools.tools.tavily_extractor_tool.tavily_extractor_tool import ( TavilyExtractorTool, @@ -305,6 +306,7 @@ "SnowflakeSearchTool", "SnowflakeSearchToolInput", "SpiderTool", + "SpritesExecTool", "StagehandTool", "TXTSearchTool", "TavilyExtractorTool", diff --git a/lib/crewai-tools/src/crewai_tools/tools/sprites_tool/__init__.py b/lib/crewai-tools/src/crewai_tools/tools/sprites_tool/__init__.py new file mode 100644 index 0000000000..5b55feb6e0 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/sprites_tool/__init__.py @@ -0,0 +1,4 @@ +from crewai_tools.tools.sprites_tool.sprites_exec_tool import SpritesExecTool + + +__all__ = ["SpritesExecTool"] diff --git a/lib/crewai-tools/src/crewai_tools/tools/sprites_tool/_execution.py b/lib/crewai-tools/src/crewai_tools/tools/sprites_tool/_execution.py new file mode 100644 index 0000000000..03b56dcca5 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/sprites_tool/_execution.py @@ -0,0 +1,83 @@ +"""Bounded output collection using the sprites-py 0.6 WebSocket transport. + +The SDK's high-level run and streaming APIs both buffer all output. Intercept +output before its collector, while retaining its connection and exit handling. +Keep this adapter covered by tests against the installed, version-bounded SDK. +""" + +from __future__ import annotations + +import codecs +from contextlib import suppress +from typing import TYPE_CHECKING + +from sprites.websocket import StreamID, WSCommand + + +if TYPE_CHECKING: + from sprites.exec import Cmd + + +class _BoundedOutput: + """Retain a UTF-8 prefix without accumulating discarded output.""" + + def __init__(self, limit: int) -> None: + """Allocate an incremental decoder and a character-bounded collector.""" + self.limit = limit + self.length = 0 + self.parts: list[str] = [] + self.truncated = False + self.decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + + def write(self, data: bytes, *, final: bool = False) -> None: + """Decode only enough bytes to retain the prefix and detect overflow.""" + if self.truncated: + return + remaining = self.limit - self.length + # UTF-8 needs at most four bytes per character. One extra character + # distinguishes exact-length output from truncated output. + byte_limit = 4 * (remaining + 1) + decoded = self.decoder.decode(data[:byte_limit], final=final) + prefix = decoded[:remaining] + if prefix: + self.parts.append(prefix) + self.length += len(prefix) + self.truncated = len(decoded) > remaining or len(data) > byte_limit + + def text(self) -> str: + """Flush any incomplete final UTF-8 sequence and return the prefix.""" + self.write(b"", final=True) + return "".join(self.parts) + + +class BoundedWSCommand(WSCommand): + """Drain a non-TTY command while retaining at most limit chars per stream.""" + + def __init__(self, cmd: Cmd, limit: int) -> None: + """Attach bounded collectors before the SDK starts receiving frames.""" + super().__init__(cmd) + self.stdout = _BoundedOutput(limit) + self.stderr = _BoundedOutput(limit) + + async def _handle_message(self, message: str | bytes) -> None: + """Intercept output; delegate control and exit frames to the SDK.""" + if self.cmd.tty: + raise RuntimeError("SpritesExecTool requires non-TTY command output.") + if isinstance(message, bytes) and message: + if message[0] == StreamID.STDOUT: + self.stdout.write(message[1:]) + return + if message[0] == StreamID.STDERR: + self.stderr.write(message[1:]) + return + await super()._handle_message(message) + + async def execute(self) -> int: + """Run and close the local connection on success, error, or cancellation.""" + try: + await self.start() + return await self.wait() + finally: + # A close failure must not hide the exit status or original error. + with suppress(Exception): + await self.close() diff --git a/lib/crewai-tools/src/crewai_tools/tools/sprites_tool/sprites_exec_tool.py b/lib/crewai-tools/src/crewai_tools/tools/sprites_tool/sprites_exec_tool.py new file mode 100644 index 0000000000..bd4b982ffd --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/sprites_tool/sprites_exec_tool.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import asyncio +import os +from typing import TYPE_CHECKING, Any + +from crewai.tools import BaseTool, EnvVar +from crewai.types.callback import SerializableCallable +from pydantic import BaseModel, Field, SecretStr, field_validator + + +if TYPE_CHECKING: + from crewai.tools.structured_tool import CrewStructuredTool + + +def _no_cache(_args: Any = None, _result: Any = None) -> bool: + """Commands can change persistent state and must not reuse cached results.""" + return False + + +class SpritesExecToolSchema(BaseModel): + """Agent-supplied command and optional remote working directory.""" + + command: str = Field( + ..., + min_length=1, + description="Shell command to execute in the configured Sprite.", + ) + cwd: str | None = Field( + default=None, description="Working directory inside the Sprite." + ) + + @field_validator("command", "cwd") + @classmethod + def validate_command_argument(cls, value: str | None) -> str | None: + """Reject blank arguments and NUL bytes before opening a connection.""" + if value is not None and (not value.strip() or "\x00" in value): + raise ValueError( + "Command and working directory must be nonblank and contain no NUL bytes." + ) + return value + + +class SpritesExecTool(BaseTool): + """Run shell commands in an existing, caller-managed Fly.io Sprite. + + This tool does not create or delete Sprites. Each call opens a new shell; + files persist, but shell variables and changes of directory do not. + """ + + name: str = "Fly.io Sprites Exec" + description: str = ( + "Execute a shell command in a configured, persistent Fly.io Sprite and " + "return its exit code, stdout, and stderr. Use this to run code, inspect " + "files, or install packages in the remote environment. Commands can " + "modify or delete files; only execute commands appropriate for the task." + ) + args_schema: type[BaseModel] = SpritesExecToolSchema + package_dependencies: list[str] = Field(default_factory=lambda: ["sprites-py"]) + sprite_name: str = Field( + ..., + min_length=1, + description="Name of an existing Sprite accessible to the token.", + ) + api_key: SecretStr | None = Field( + default_factory=lambda: ( + SecretStr(value) if (value := os.getenv("SPRITE_TOKEN")) else None + ), + exclude=True, + repr=False, + description="Sprites API token. Defaults to the SPRITE_TOKEN environment variable.", + ) + timeout: float = Field( + default=60, gt=0, le=300, description="Maximum seconds to wait for a command." + ) + max_output_chars: int = Field( + default=20_000, + gt=0, + description="Maximum characters returned per output stream.", + ) + cache_function: SerializableCallable = Field(default=_no_cache) + env_vars: list[EnvVar] = Field( + default_factory=lambda: [ + EnvVar(name="SPRITE_TOKEN", description="Fly.io Sprites API token") + ] + ) + + @field_validator("sprite_name") + @classmethod + def validate_sprite_name(cls, value: str) -> str: + """Require a single Sprite name rather than a URL or path.""" + # The SDK interpolates this value into API paths. Require a single name. + if value in {".", ".."} or any( + char.isspace() or char in "/\\?#%" or ord(char) < 32 or ord(char) == 127 + for char in value + ): + raise ValueError("sprite_name must be a Sprite name, not a URL or path.") + return value + + def _run(self, command: str, cwd: str | None = None) -> dict[str, str | int | bool]: + """Run the async transport on the SDK's dedicated loop for sync callers.""" + try: + from sprites.loop import run_sync + except ImportError: + raise ImportError( + 'Install Fly.io Sprites support with: uv add "crewai-tools[sprites]"' + ) from None + return run_sync(self._arun(command, cwd)) + + def to_structured_tool(self) -> CrewStructuredTool: + """Preserve native async dispatch when CrewAI adapts this tool.""" + structured_tool = super().to_structured_tool() + structured_tool.func = self._arun + return structured_tool + + async def _arun( + self, command: str, cwd: str | None = None + ) -> dict[str, str | int | bool]: + """Await bounded SDK I/O directly so cancellation leaves no worker behind.""" + inputs = SpritesExecToolSchema(command=command, cwd=cwd) + if self.api_key is None or not self.api_key.get_secret_value().strip(): + raise ValueError("Set SPRITE_TOKEN or pass api_key to SpritesExecTool.") + try: + from sprites import SpritesClient + from sprites.exceptions import TimeoutError as SpritesTimeoutError + + from crewai_tools.tools.sprites_tool._execution import BoundedWSCommand + except ImportError: + raise ImportError( + 'Install Fly.io Sprites support with: uv add "crewai-tools[sprites]"' + ) from None + + try: + with SpritesClient(token=self.api_key.get_secret_value()) as client: + cmd = client.sprite(self.sprite_name).command( + "bash", + "-lc", + inputs.command, + cwd=inputs.cwd, + timeout=self.timeout, + ) + execution = BoundedWSCommand(cmd, self.max_output_chars) + exit_code = await asyncio.wait_for( + execution.execute(), timeout=self.timeout + ) + except (TimeoutError, SpritesTimeoutError): + raise TimeoutError( + "Timed out waiting for the Sprite command. It may still be running; " + "inspect the Sprite before retrying commands with side effects." + ) from None + except Exception: + # SDK errors can include request details. Do not surface credentials + # or suggest retrying a command whose remote outcome is unknown. + raise RuntimeError( + "Fly.io Sprites execution failed. Check the configured Sprite, " + "credentials, and connectivity. The command outcome may be unknown; " + "inspect the Sprite before retrying commands with side effects." + ) from None + + stdout = execution.stdout.text() + stderr = execution.stderr.text() + return { + "exit_code": exit_code, + "stdout": stdout, + "stderr": stderr, + "stdout_truncated": execution.stdout.truncated, + "stderr_truncated": execution.stderr.truncated, + } diff --git a/lib/crewai-tools/tests/tools/test_sprites_exec_tool.py b/lib/crewai-tools/tests/tools/test_sprites_exec_tool.py new file mode 100644 index 0000000000..f94dd7cb49 --- /dev/null +++ b/lib/crewai-tools/tests/tools/test_sprites_exec_tool.py @@ -0,0 +1,526 @@ +import asyncio +import builtins +from collections.abc import AsyncIterator, Iterable +import importlib +from typing import Any +from unittest.mock import AsyncMock, create_autospec + +from crewai_tools import SpritesExecTool +from crewai_tools.tools import SpritesExecTool as ExportedSpritesExecTool +from crewai_tools.tools.sprites_tool.sprites_exec_tool import SpritesExecToolSchema +from pydantic import ValidationError +import pytest + + +class FakeSocket: + """Replace network I/O while exercising the installed SDK's receive loop.""" + + def __init__(self) -> None: + """Provide a successful command by default, with optional blocking I/O.""" + self.messages: Iterable[str | bytes] = [b"\x01hello\n", b"\x03\x00"] + self.block = False + self.reading = False + self.started = asyncio.Event() + self.release = asyncio.Event() + self.close = AsyncMock() + self.send = AsyncMock() + self.close_code = 1000 + self.close_reason = "" + + async def __aiter__(self) -> AsyncIterator[str | bytes]: + """Yield frames, or await cancellation like an idle WebSocket.""" + self.reading = True + self.started.set() + try: + if self.block: + await self.release.wait() + for message in self.messages: + yield message + await asyncio.sleep(0) + finally: + self.reading = False + + +@pytest.fixture +def sdk(monkeypatch: pytest.MonkeyPatch) -> tuple[Any, Any, Any, FakeSocket]: + """Mock client construction and the socket, retaining real SDK commands.""" + sprites = pytest.importorskip("sprites") + from sprites import websocket + from sprites.exec import Cmd + + factory = create_autospec(sprites.SpritesClient) + client = factory.return_value.__enter__.return_value + client.base_url = "https://api.sprites.dev" + client.token = "test-sprites-token" + sprite = create_autospec(sprites.Sprite, instance=True) + sprite.name = "crew-workspace" + sprite.client = client + sprite.command.side_effect = lambda *args, **kwargs: Cmd( + sprite, list(args), **kwargs + ) + client.sprite.return_value = sprite + socket = FakeSocket() + monkeypatch.setattr(websocket, "connect", AsyncMock(return_value=socket)) + monkeypatch.setattr(sprites, "SpritesClient", factory) + return factory, client, sprite, socket + + +@pytest.fixture +def tool(monkeypatch: pytest.MonkeyPatch) -> SpritesExecTool: + """Construct a tool with a deliberately fake environment token.""" + monkeypatch.setenv("SPRITE_TOKEN", "test-sprites-token") + return SpritesExecTool(sprite_name="crew-workspace") + + +def test_public_exports() -> None: + """Expose the tool consistently through both public import paths.""" + assert SpritesExecTool is ExportedSpritesExecTool + + +def test_command_output_and_existing_sprite_lifecycle( + tool: SpritesExecTool, sdk: Any +) -> None: + """Preserve shell arguments, return output, and never change Sprite lifecycle.""" + factory, client, sprite, socket = sdk + result = tool.run(command="printf 'hello\\n'", cwd="/workspace with spaces") + assert result == { + "exit_code": 0, + "stdout": "hello\n", + "stderr": "", + "stdout_truncated": False, + "stderr_truncated": False, + } + factory.assert_called_once_with(token="test-sprites-token") + client.sprite.assert_called_once_with("crew-workspace") + sprite.command.assert_called_once_with( + "bash", + "-lc", + "printf 'hello\\n'", + cwd="/workspace with spaces", + timeout=60, + ) + socket.close.assert_awaited_once() + socket.send.assert_awaited_once_with(b"\x04") + factory.return_value.__exit__.assert_called_once() + client.create_sprite.assert_not_called() + client.destroy_sprite.assert_not_called() + + +def test_nonzero_exit_and_binary_output(tool: SpritesExecTool, sdk: Any) -> None: + """Return nonzero exits and replace invalid UTF-8 without hiding stderr.""" + sdk[3].messages = [b"\x01\xff\n", b"\x02failed\n", b"\x03\x07"] + result = tool.run(command="exit 7") + assert result["exit_code"] == 7 + assert result["stdout"] == "\ufffd\n" + assert result["stderr"] == "failed\n" + + +@pytest.mark.parametrize( + "stdout,stderr,truncated", [(b"abcdef", b"123456", True), (b"abc", b"123", False)] +) +def test_output_limits( + tool: SpritesExecTool, sdk: Any, stdout: bytes, stderr: bytes, truncated: bool +) -> None: + """Report truncation only when a stream exceeds its character limit.""" + tool.max_output_chars = 3 + sdk[3].messages = [b"\x01" + stdout, b"\x02" + stderr, b"\x03\x00"] + result = tool.run(command="generate-output") + assert result["stdout"] == "abc" + assert result["stderr"] == "123" + assert result["stdout_truncated"] is truncated + assert result["stderr_truncated"] is truncated + + +def test_empty_output(tool: SpritesExecTool, sdk: Any) -> None: + """Represent absent stdout and stderr as empty strings.""" + sdk[3].messages = [b"\x03\x00"] + result = tool.run(command="true") + assert result["stdout"] == result["stderr"] == "" + + +def test_credentials_are_not_agent_arguments_or_serialized( + tool: SpritesExecTool, sdk: Any +) -> None: + """Keep credentials out of the agent schema and serialized tool state.""" + assert tool.args_schema is SpritesExecToolSchema + assert set(tool.args_schema.model_json_schema()["properties"]) == {"command", "cwd"} + assert "api_key" not in tool.model_dump() + assert "test-sprites-token" not in tool.model_dump_json() + assert "test-sprites-token" not in repr(tool) + assert "test-sprites-token" not in tool.description + + +def test_explicit_token_overrides_environment( + sdk: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + """Prefer explicit credentials and pass through the configured timeout.""" + monkeypatch.setenv("SPRITE_TOKEN", "environment-token") + tool = SpritesExecTool( + sprite_name="crew-workspace", api_key="explicit-token", timeout=15 + ) + tool.run(command="true") + sdk[0].assert_called_once_with(token="explicit-token") + assert sdk[2].command.call_args.kwargs["timeout"] == 15 + + +@pytest.mark.parametrize("token", [None, "", " "]) +def test_missing_credentials( + token: str | None, monkeypatch: pytest.MonkeyPatch, sdk: Any +) -> None: + """Reject missing or blank credentials before opening a client.""" + monkeypatch.delenv("SPRITE_TOKEN", raising=False) + tool = SpritesExecTool(sprite_name="crew-workspace", api_key=token) + with pytest.raises(ValueError, match="SPRITE_TOKEN"): + tool.run(command="true") + sdk[0].assert_not_called() + + +def test_optional_sdk_is_lazy_and_has_install_hint( + tool: SpritesExecTool, monkeypatch: pytest.MonkeyPatch +) -> None: + """Allow importing CrewAI tools without the optional SDK installed.""" + original_import = builtins.__import__ + + def without_sprites(name: str, *args: Any, **kwargs: Any) -> Any: + """Simulate an environment without any Sprites SDK modules.""" + if name == "sprites" or name.startswith("sprites."): + raise ImportError("No module named sprites") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", without_sprites) + importlib.reload(importlib.import_module("crewai_tools")) + with pytest.raises(ImportError, match=r"crewai-tools\[sprites\]"): + tool.run(command="true") + + +@pytest.mark.parametrize("sdk_timeout", [False, True]) +def test_timeout_warns_about_unknown_remote_state_and_closes_client( + tool: SpritesExecTool, sdk: Any, sdk_timeout: bool +) -> None: + """Sanitize both timeout types without retrying a remote side effect.""" + from sprites.exceptions import TimeoutError as SpritesTimeoutError + + sdk[2].command.side_effect = (SpritesTimeoutError if sdk_timeout else TimeoutError)( + "private request" + ) + with pytest.raises(TimeoutError, match="may still be running"): + tool.run(command="long-running-command") + sdk[0].return_value.__exit__.assert_called_once() + sdk[2].command.assert_called_once() # Never automatically retry a side effect. + sdk[1].destroy_sprite.assert_not_called() + + +def test_sdk_failure_does_not_expose_credentials_or_retry( + tool: SpritesExecTool, sdk: Any +) -> None: + """Suppress credential-bearing SDK errors and leave remote state alone.""" + sdk[2].command.side_effect = RuntimeError( + "Authorization: Bearer test-sprites-token" + ) + with pytest.raises(RuntimeError, match="outcome may be unknown") as error: + tool.run(command="write-file") + assert "test-sprites-token" not in str(error.value) + assert error.value.__suppress_context__ + sdk[0].return_value.__exit__.assert_called_once() + sdk[2].command.assert_called_once() + + +@pytest.mark.parametrize( + "name", ["", " ", "../other", ".", "..", "a?b", "a#b", "a%2fb", "a\\b", "a\x00b"] +) +def test_invalid_sprite_names(name: str) -> None: + """Disallow invalid names and path/query injection through the Sprite name.""" + with pytest.raises(ValidationError): + SpritesExecTool(sprite_name=name) + + +@pytest.mark.parametrize( + "kwargs", + [ + {"timeout": 0}, + {"timeout": -1}, + {"timeout": 301}, + {"timeout": float("nan")}, + {"timeout": float("inf")}, + {"max_output_chars": 0}, + ], +) +def test_invalid_limits(kwargs: dict[str, Any]) -> None: + """Reject nonpositive limits and timeouts beyond the documented bound.""" + with pytest.raises(ValidationError): + SpritesExecTool(sprite_name="crew-workspace", **kwargs) + + +@pytest.mark.parametrize( + "kwargs", + [ + {"command": ""}, + {"command": " "}, + {"command": "a\x00b"}, + {"command": "true", "cwd": " "}, + {"command": "true", "cwd": "a\x00b"}, + ], +) +def test_invalid_arguments( + tool: SpritesExecTool, sdk: Any, kwargs: dict[str, str] +) -> None: + """Validate command arguments before SDK client construction.""" + with pytest.raises(ValueError): + tool.run(**kwargs) + sdk[0].assert_not_called() + + +def test_commands_are_not_cacheable(tool: SpritesExecTool) -> None: + """Prevent cached results from skipping side-effecting commands.""" + assert tool.cache_function({"command": "true"}, {"exit_code": 0}) is False + assert tool.to_structured_tool().cache_function({}, {}) is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize("structured", [False, True]) +async def test_async_execution_avoids_executor( + tool: SpritesExecTool, sdk: Any, monkeypatch: pytest.MonkeyPatch, structured: bool +) -> None: + """Run concurrent async commands without submitting any executor work.""" + submit = create_autospec(asyncio.get_running_loop().run_in_executor) + monkeypatch.setattr(asyncio.get_running_loop(), "run_in_executor", submit) + adapter = tool.to_structured_tool() + outputs = await asyncio.gather( + *( + adapter.ainvoke({"command": "true"}) + if structured + else tool.arun(command="true") + for _ in range(2) + ) + ) + assert all(output["exit_code"] == 0 for output in outputs) + submit.assert_not_called() + assert sdk[0].return_value.__exit__.call_count == 2 + + +def test_real_sdk_command_contract( + monkeypatch: pytest.MonkeyPatch, tool: SpritesExecTool +) -> None: + """Exercise the real client, SDK transport and CrewAI adapter without network.""" + from urllib.parse import parse_qs, urlsplit + + from sprites import websocket + + socket = FakeSocket() + socket.messages = [b"\x01hello", b"\x02warning", b"\x03\x03"] + connect = AsyncMock(return_value=socket) + monkeypatch.setattr(websocket, "connect", connect) + result = tool.to_structured_tool().invoke( + {"command": "printf hello", "cwd": "/workspace"} + ) + assert result["exit_code"] == 3 + assert result["stdout"] == "hello" + assert result["stderr"] == "warning" + url = urlsplit(connect.call_args.args[0]) + assert url.scheme == "wss" + assert url.path == "/v1/sprites/crew-workspace/exec" + query = parse_qs(url.query) + assert query["cmd"] == ["bash", "-lc", "printf hello"] + assert query["dir"] == ["/workspace"] + assert "env" not in query # Never inject the host token into remote commands. + assert connect.call_args.kwargs["additional_headers"]["Authorization"] == ( + "Bearer test-sprites-token" + ) + socket.close.assert_awaited_once() + + +@pytest.mark.parametrize("frames", [[b"abc"], [b"ab", b"c"], [b"abcdef"]]) +def test_utf8_prefix_across_frames( + tool: SpritesExecTool, sdk: Any, frames: list[bytes] +) -> None: + """Decode split multibyte sequences and count characters, not bytes.""" + tool.max_output_chars = 3 + emoji = "😀".encode() + sdk[3].messages = [b"\x01" + emoji[:2], b"\x01" + emoji[2:], b"\x01\xc3"] + sdk[3].messages += ( + [b"\x01\xa9"] + [b"\x01" + part for part in frames] + [b"\x03\x00"] + ) + result = tool.run(command="unicode-output") + assert result["stdout"] == "😀éa" + assert result["stdout_truncated"] is True + + +@pytest.mark.parametrize( + "data,expected,truncated", + [(b"ab\xe2", "ab�", False), (b"abc\xe2", "abc", True), (b"abc", "abc", False)], +) +def test_incomplete_utf8_at_exit( + tool: SpritesExecTool, sdk: Any, data: bytes, expected: str, truncated: bool +) -> None: + """Flush an incomplete final code point before computing truncation flags.""" + tool.max_output_chars = 3 + sdk[3].messages = [b"\x01" + data, b"\x03\x00"] + result = tool.run(command="binary-output") + assert result["stdout"] == expected + assert result["stdout_truncated"] is truncated + + +@pytest.mark.asyncio +async def test_large_output_never_accumulates_in_sdk(sdk: Any) -> None: + """Drain many frames past the cap without growing either SDK output buffer.""" + from crewai_tools.tools.sprites_tool._execution import BoundedWSCommand + + command = sdk[2].command("bash", "-lc", "noisy-command") + execution = BoundedWSCommand(command, 3) + + def frames() -> Iterable[bytes]: + """Generate megabytes without preallocating a complete result.""" + for _ in range(512): + yield b"\x01" + b"a" * 8192 + yield b"\x02" + b"b" * 8192 + assert execution.stdout.length <= 3 + assert execution.stderr.length <= 3 + assert execution.get_stdout() == execution.get_stderr() == b"" + assert sum(map(len, execution.stdout.parts)) <= 3 + assert sum(map(len, execution.stderr.parts)) <= 3 + yield b"\x03\x07" + + sdk[3].messages = frames() + assert await execution.execute() == 7 + assert execution.stdout.text() == "aaa" + assert execution.stderr.text() == "bbb" + assert execution.stdout.truncated and execution.stderr.truncated + sdk[3].close.assert_awaited_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("structured", [False, True]) +async def test_cancellation_closes_connection_without_executor( + tool: SpritesExecTool, sdk: Any, monkeypatch: pytest.MonkeyPatch, structured: bool +) -> None: + """Repeated cancellations leave neither shared workers nor receive tasks.""" + from sprites import websocket + + submit = create_autospec(asyncio.get_running_loop().run_in_executor) + monkeypatch.setattr(asyncio.get_running_loop(), "run_in_executor", submit) + baseline = asyncio.all_tasks() + for _ in range(5): + socket = FakeSocket() + socket.block = True + monkeypatch.setattr(websocket, "connect", AsyncMock(return_value=socket)) + task = asyncio.create_task( + tool.to_structured_tool().ainvoke({"command": "long-command"}) + if structured + else tool.arun(command="long-command") + ) + await asyncio.wait_for(socket.started.wait(), timeout=1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert not socket.reading + socket.close.assert_awaited_once() + assert asyncio.all_tasks() == baseline + submit.assert_not_called() + assert sdk[0].return_value.__exit__.call_count == 5 + sdk[1].destroy_sprite.assert_not_called() + + +@pytest.mark.asyncio +async def test_timeout_cancels_receive_and_closes_connection( + tool: SpritesExecTool, sdk: Any +) -> None: + """Enforce the configured deadline while cleaning up local I/O.""" + tool.timeout = 0.02 + sdk[3].block = True + with pytest.raises(TimeoutError, match="may still be running"): + await tool.arun(command="long-command") + assert not sdk[3].reading + sdk[3].close.assert_awaited_once() + sdk[0].return_value.__exit__.assert_called_once() + + +@pytest.mark.parametrize("cancel", [True, False]) +@pytest.mark.asyncio +async def test_interrupt_during_connection_setup( + tool: SpritesExecTool, sdk: Any, monkeypatch: pytest.MonkeyPatch, cancel: bool +) -> None: + """Cancel or time out a pending connection without leaving a connect task.""" + from sprites import websocket + + started = asyncio.Event() + finished = asyncio.Event() + + async def connect(*args: Any, **kwargs: Any) -> None: + """Remain in connection setup until the task is cancelled.""" + started.set() + try: + await asyncio.Event().wait() + finally: + finished.set() + + monkeypatch.setattr(websocket, "connect", connect) + tool.timeout = 1 if cancel else 0.02 + task = asyncio.create_task(tool.arun(command="true")) + await asyncio.wait_for(started.wait(), timeout=1) + if cancel: + task.cancel() + with pytest.raises(asyncio.CancelledError if cancel else TimeoutError): + await task + assert finished.is_set() + sdk[0].return_value.__exit__.assert_called_once() + + +def test_disconnect_before_exit_is_not_success(tool: SpritesExecTool, sdk: Any) -> None: + """Treat a transport close without an exit frame as an unknown outcome.""" + sdk[3].messages = [b"\x01partial"] + with pytest.raises(RuntimeError, match="outcome may be unknown"): + tool.run(command="write-file") + sdk[3].close.assert_awaited_once() + + +def test_close_failure_preserves_command_exit(tool: SpritesExecTool, sdk: Any) -> None: + """Keep a known command result even if the close handshake fails.""" + sdk[3].close.side_effect = RuntimeError("close failed") + assert tool.run(command="true")["exit_code"] == 0 + + +@pytest.mark.parametrize("limit", [1, 3, 20]) +@pytest.mark.parametrize("chunk_size", [1, 2, 7, 1024]) +def test_bounded_collector_matches_utf8_decode(limit: int, chunk_size: int) -> None: + """Match full UTF-8 decoding for valid, invalid, and split byte sequences.""" + from crewai_tools.tools.sprites_tool._execution import _BoundedOutput + + for data in ( + b"", + "aé😀日".encode() * 50, + bytes(range(256)) * 5, + b"\xf0\x9f\x98", + b"abc\xf0\x9f\x98", + b"\xed\xa0\x80\xff\xc0\xaf" * 20, + ): + output = _BoundedOutput(limit) + for start in range(0, len(data), chunk_size): + output.write(data[start : start + chunk_size]) + assert output.length <= limit + assert sum(map(len, output.parts)) <= limit + expected = data.decode("utf-8", errors="replace") + assert output.text() == expected[:limit] + assert output.truncated is (len(expected) > limit) + + +def test_control_frames_and_text_exit(tool: SpritesExecTool, sdk: Any) -> None: + """Leave session metadata and text exit handling with the real SDK.""" + sdk[3].messages = [ + '{"type":"session_info","tty":false}', + b"", + b"\x01hello", + '{"type":"exit","exit_code":9}', + ] + result = tool.run(command="true") + assert result["stdout"] == "hello" + assert result["exit_code"] == 9 + + +def test_unexpected_tty_does_not_fall_back_to_unbounded_capture( + tool: SpritesExecTool, sdk: Any +) -> None: + """Fail closed if the server unexpectedly switches to a TTY protocol.""" + sdk[3].messages = ['{"type":"session_info","tty":true}', b"raw-output"] + with pytest.raises(RuntimeError, match="outcome may be unknown"): + tool.run(command="true") + sdk[3].close.assert_awaited_once() diff --git a/uv.lock b/uv.lock index 463f0beb0e..5235ddb1e3 100644 --- a/uv.lock +++ b/uv.lock @@ -17,7 +17,7 @@ resolution-markers = [ ] [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-09-04T04:51:35.712943Z" exclude-newer-span = "P3D" [options.exclude-newer-package] @@ -1125,6 +1125,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, ] +[[package]] +name = "client-signals" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/dd/5002f106210ebb4772171e80f206c395e1ac38532c7d601a883903b9b7e6/client_signals-0.4.4.tar.gz", hash = "sha256:1fc1846b9992706349a10c10886f0f326bc56eeedd9af612c41cf9ff1a89562d", size = 3851, upload-time = "2026-08-05T14:46:53.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/76/e8f0947c8a26ec9fd0ccc960ed3875aec55035afb19bd6c08e9eac574861/client_signals-0.4.4-py3-none-any.whl", hash = "sha256:672551d797c004cfc9d5ae87ed0dca4ba5a64fc35d1edcf1218cc11f12fdfb25", size = 4347, upload-time = "2026-08-05T14:46:52.186Z" }, +] + [[package]] name = "cloudpathlib" version = "0.25.0" @@ -1830,6 +1839,9 @@ snowflake = [ spider-client = [ { name = "spider-client" }, ] +sprites = [ + { name = "sprites-py" }, +] sqlalchemy = [ { name = "sqlalchemy" }, ] @@ -1899,6 +1911,7 @@ requires-dist = [ { name = "snowflake-connector-python", marker = "extra == 'snowflake'", specifier = ">=3.12.4" }, { name = "snowflake-sqlalchemy", marker = "extra == 'snowflake'", specifier = ">=1.11.0" }, { name = "spider-client", marker = "extra == 'spider-client'", specifier = ">=0.1.25" }, + { name = "sprites-py", marker = "extra == 'sprites'", specifier = ">=0.6.0,<0.7" }, { name = "sqlalchemy", marker = "extra == 'singlestore'", specifier = ">=2.0.40" }, { name = "sqlalchemy", marker = "extra == 'sqlalchemy'", specifier = ">=2.0.35" }, { name = "stagehand", marker = "extra == 'stagehand'", specifier = ">=0.4.1" }, @@ -1909,7 +1922,7 @@ requires-dist = [ { name = "weaviate-client", marker = "extra == 'weaviate-client'", specifier = ">=4.10.2" }, { name = "youtube-transcript-api", specifier = "~=1.2.2" }, ] -provides-extras = ["apify", "beautifulsoup4", "bedrock", "browserbase", "composio-core", "contextual", "couchbase", "databricks-sdk", "daytona", "e2b", "exa-py", "firecrawl-py", "github", "hyperbrowser", "linkup-sdk", "mcp", "mongodb", "multion", "mysql", "oxylabs", "patronus", "postgresql", "qdrant-client", "rag", "scrapegraph-py", "scrapfly-sdk", "selenium", "serpapi", "singlestore", "snowflake", "spider-client", "sqlalchemy", "stagehand", "tavily-python", "weaviate-client", "xml"] +provides-extras = ["apify", "beautifulsoup4", "bedrock", "browserbase", "composio-core", "contextual", "couchbase", "databricks-sdk", "daytona", "e2b", "exa-py", "firecrawl-py", "github", "hyperbrowser", "linkup-sdk", "mcp", "mongodb", "multion", "mysql", "oxylabs", "patronus", "postgresql", "qdrant-client", "rag", "scrapegraph-py", "scrapfly-sdk", "selenium", "serpapi", "singlestore", "snowflake", "spider-client", "sprites", "sqlalchemy", "stagehand", "tavily-python", "weaviate-client", "xml"] [[package]] name = "cryptography" @@ -9010,6 +9023,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/0f/76a88ab646d57e64079830c73a183d55b030ba5b334276850837998ceb9f/spider_client-0.1.88-py3-none-any.whl", hash = "sha256:5f72acfc979cf45223c4fec3a099ffaab28921dc1867abc965aeb62582768be5", size = 16782, upload-time = "2026-03-20T01:42:03.983Z" }, ] +[[package]] +name = "sprites-py" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "client-signals" }, + { name = "httpx" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/11/63fac634575f187541d2a4aba02a6996ea934672569de6ab44c214817775/sprites_py-0.6.0.tar.gz", hash = "sha256:a48682faeaa39b8f635c1c18ac3d5a3709859050206c2977efdc5dd3fa886fc6", size = 54091, upload-time = "2026-09-01T15:53:30.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/62/bf5fb2465921556c2739b1daeee290b5f7920993cc6b0c102b25497b8393/sprites_py-0.6.0-py3-none-any.whl", hash = "sha256:26c8f038fd9c8e3cae39e254c04214ff7e4d6edaf46c9bb2806e2bc0d3752064", size = 46659, upload-time = "2026-09-01T15:53:29.019Z" }, +] + [[package]] name = "sqlalchemy" version = "2.0.50"