headroom/tests/test_integrations/crewai/test_agents.py
Sneha Roy e8bff1cfe3
feat: add CrewAI and AutoGen tool compression integrations (#1384)
## Description

Add CrewAI and AutoGen tool compression integrations, following the same
patterns as the existing LangChain agent integration
(`HeadroomToolWrapper` / `wrap_tools_with_headroom`). Both delegate
compression to `compress_tool_result()` from the MCP integration, with
per-tool metrics tracking via `ToolCompressionMetrics` /
`ToolMetricsCollector`.

Closes #1379

## Type of Change

- [x] New feature (non-breaking change that adds functionality)

## Changes Made

- Add `headroom/integrations/crewai/` — `HeadroomToolWrapper` subclasses
CrewAI `BaseTool`, wraps `_run()` with compression
- Add `headroom/integrations/autogen/` — `HeadroomToolWrapper` wraps
AutoGen `FunctionTool` (sync and async) with compression
- Wire both into `headroom/integrations/__init__.py` with aliased
re-exports (avoids name collision with LangChain's
`HeadroomToolWrapper`)
- Add `[crewai]` and `[autogen]` optional dependency extras to
`pyproject.toml`
- Add 24 unit tests (12 per framework) under `tests/test_integrations/`
- Add `.mdx` doc pages for both frameworks under `docs/content/docs/`
- Update `CHANGELOG.md` with entries under `### Added`

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ ruff check headroom/integrations/crewai headroom/integrations/autogen tests/test_integrations/crewai tests/test_integrations/autogen
All checks passed!

$ pytest tests/test_integrations/autogen -v
12 passed

$ pytest tests/test_integrations/crewai -v
12 passed
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.11, crewai 1.14.7, autogen-agentchat
0.7.5
- Exact command / steps: Ran standalone adapter demos and benchmark
runner across 4 task types
- Observed result:

| Task | Tokens (raw) | Tokens (compressed) | Savings |
|------|-------------|-------------------|---------|
| Inventory JSON (80 items) | 5,044 | 1,532 | 69.6% |
| Server logs (150 lines) | 8,712 | 314 | 96.4% |
| Analytics query (100 rows) | 10,762 | 10,762 | 0% |
| API docs (20 endpoints) | 8,043 | 8,043 | 0% |

Compression results are identical across CrewAI and AutoGen — expected
since both route through the same `compress_tool_result()` pipeline.

- Not tested: Full end-to-end with a live LLM agent loop (demos test the
compression pipeline standalone). LangGraph not included — headroom
already has `headroom/integrations/langchain/langgraph.py`.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

- LangGraph integration is intentionally excluded — headroom already has
one at `headroom/integrations/langchain/langgraph.py`
- Re-exports in `__init__.py` are aliased (`CrewAIToolWrapper`,
`AutoGenToolWrapper`) to avoid collision with the existing LangChain
`HeadroomToolWrapper`
- Both integrations follow the exact same conventions as the existing
LangChain agents module: optional dep guard, `compress_tool_result()`
delegation, metrics with 1000-entry cap, Google-style docstrings
- `mypy` not checked due to Rust build dependency (`maturin`) that
requires Application Control policy changes on this machine

---------

Co-authored-by: Sneha27feb <sroy27.ai@gmail.com>
2026-07-15 19:58:54 +00:00

262 lines
8.1 KiB
Python

"""Tests for CrewAI agent tool integration.
Tests cover:
1. ToolCompressionMetrics - Dataclass for tool compression metrics
2. ToolMetricsCollector - Collector for compression metrics
3. HeadroomToolWrapper - Wrapper for CrewAI tools with compression
4. wrap_tools_with_headroom - Convenience function for wrapping multiple tools
5. get_tool_metrics / reset_tool_metrics - Global metrics access
"""
from datetime import datetime
from unittest.mock import patch
import pytest
try:
from crewai.tools.base_tool import tool as crewai_tool
CREWAI_AVAILABLE = True
except ImportError:
CREWAI_AVAILABLE = False
pytestmark = pytest.mark.skipif(not CREWAI_AVAILABLE, reason="CrewAI not installed")
def _make_large_output(n: int = 200) -> str:
"""Create a large JSON string to trigger compression."""
import json
return json.dumps({"items": [{"id": i, "data": "x" * 50} for i in range(n)]})
class TestToolCompressionMetrics:
"""Tests for ToolCompressionMetrics dataclass."""
def test_create_metrics(self):
from headroom.integrations.crewai.agents import ToolCompressionMetrics
metrics = ToolCompressionMetrics(
tool_name="search",
timestamp=datetime.now(),
chars_before=5000,
chars_after=2000,
chars_saved=3000,
compression_ratio=0.4,
was_compressed=True,
)
assert metrics.tool_name == "search"
assert metrics.chars_before == 5000
assert metrics.chars_saved == 3000
assert metrics.was_compressed is True
def test_metrics_all_fields_required(self):
from headroom.integrations.crewai.agents import ToolCompressionMetrics
with pytest.raises(TypeError):
ToolCompressionMetrics() # type: ignore[call-arg]
class TestToolMetricsCollector:
"""Tests for ToolMetricsCollector."""
def test_empty_summary(self):
from headroom.integrations.crewai.agents import ToolMetricsCollector
collector = ToolMetricsCollector()
summary = collector.get_summary()
assert summary["total_invocations"] == 0
assert summary["total_compressions"] == 0
def test_add_and_summary(self):
from headroom.integrations.crewai.agents import (
ToolCompressionMetrics,
ToolMetricsCollector,
)
collector = ToolMetricsCollector()
collector.add(
ToolCompressionMetrics(
tool_name="search",
timestamp=datetime.now(),
chars_before=5000,
chars_after=2000,
chars_saved=3000,
compression_ratio=0.4,
was_compressed=True,
)
)
summary = collector.get_summary()
assert summary["total_invocations"] == 1
assert summary["total_compressions"] == 1
assert summary["total_chars_saved"] == 3000
assert "search" in summary["by_tool"]
def test_caps_at_1000(self):
from headroom.integrations.crewai.agents import (
ToolCompressionMetrics,
ToolMetricsCollector,
)
collector = ToolMetricsCollector()
for _i in range(1050):
collector.add(
ToolCompressionMetrics(
tool_name="t",
timestamp=datetime.now(),
chars_before=100,
chars_after=100,
chars_saved=0,
compression_ratio=1.0,
was_compressed=False,
)
)
assert len(collector.metrics) == 1000
class TestGlobalMetrics:
"""Tests for global metrics functions."""
def test_get_and_reset(self):
from headroom.integrations.crewai.agents import get_tool_metrics, reset_tool_metrics
metrics = get_tool_metrics()
assert metrics is not None
reset_tool_metrics()
assert get_tool_metrics() is not metrics
class TestHeadroomToolWrapper:
"""Tests for HeadroomToolWrapper."""
@patch("headroom.integrations.crewai.agents.compress_tool_result")
def test_skips_short_output(self, mock_compress):
from headroom.integrations.crewai.agents import HeadroomToolWrapper, ToolMetricsCollector
@crewai_tool
def small_tool(query: str) -> str:
"""Return small output."""
return "short"
collector = ToolMetricsCollector()
wrapper = HeadroomToolWrapper(
small_tool,
min_chars_to_compress=1000,
metrics_collector=collector,
)
result = wrapper.run(query="test")
assert result == "short"
mock_compress.assert_not_called()
assert collector.get_summary()["total_compressions"] == 0
@patch("headroom.integrations.crewai.agents.compress_tool_result")
def test_compresses_large_output(self, mock_compress):
from headroom.integrations.crewai.agents import HeadroomToolWrapper, ToolMetricsCollector
large = _make_large_output()
mock_compress.return_value = "compressed"
@crewai_tool
def big_tool(query: str) -> str:
"""Return large output."""
return large
collector = ToolMetricsCollector()
wrapper = HeadroomToolWrapper(
big_tool,
min_chars_to_compress=100,
metrics_collector=collector,
)
result = wrapper.run(query="test")
assert result == "compressed"
mock_compress.assert_called_once()
assert collector.get_summary()["total_compressions"] == 1
@patch(
"headroom.integrations.crewai.agents.compress_tool_result",
side_effect=RuntimeError("boom"),
)
def test_passes_through_on_error(self, mock_compress):
from headroom.integrations.crewai.agents import HeadroomToolWrapper, ToolMetricsCollector
large = _make_large_output()
@crewai_tool
def flaky_tool(query: str) -> str:
"""Return large output."""
return large
collector = ToolMetricsCollector()
wrapper = HeadroomToolWrapper(
flaky_tool,
min_chars_to_compress=100,
metrics_collector=collector,
)
result = wrapper.run(query="test")
assert result == large
assert collector.get_summary()["total_compressions"] == 0
def test_preserves_tool_metadata(self):
from headroom.integrations.crewai.agents import HeadroomToolWrapper
@crewai_tool
def my_fn(x: int) -> str:
"""Do something useful."""
return str(x)
wrapper = HeadroomToolWrapper(my_fn)
assert wrapper.name == "my_fn"
assert wrapper.description == "Do something useful."
class TestWrapToolsWithHeadroom:
"""Tests for wrap_tools_with_headroom convenience function."""
@patch("headroom.integrations.crewai.agents.compress_tool_result")
def test_wraps_multiple_tools(self, mock_compress):
from headroom.integrations.crewai.agents import wrap_tools_with_headroom
@crewai_tool
def tool_a(q: str) -> str:
"""Tool A."""
return "a"
@crewai_tool
def tool_b(q: str) -> str:
"""Tool B."""
return "b"
wrapped = wrap_tools_with_headroom([tool_a, tool_b])
assert len(wrapped) == 2
assert wrapped[0].name == "tool_a"
assert wrapped[1].name == "tool_b"
@patch("headroom.integrations.crewai.agents.compress_tool_result")
def test_shared_metrics(self, mock_compress):
from headroom.integrations.crewai.agents import (
ToolMetricsCollector,
wrap_tools_with_headroom,
)
large = _make_large_output()
mock_compress.return_value = "compressed"
@crewai_tool
def big(q: str) -> str:
"""Big tool."""
return large
collector = ToolMetricsCollector()
wrapped = wrap_tools_with_headroom(
[big],
min_chars_to_compress=100,
metrics_collector=collector,
)
wrapped[0].run(q="test")
assert collector.get_summary()["total_invocations"] == 1