Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,13 @@ jobs:
pip-${{ runner.os }}-python-${{ matrix.python-version }}-

- name: Install dependencies
if: steps.cache-deps.outputs.cache-hit != 'true'
run: |
python -m pip install --upgrade pip
python -m pip install -e ".[dev]" --user
python -m pip install -e ".[dev]"

- name: Run tests with pytest
run: |
pytest tests/ -v --cov=tabstack --cov-report=term-missing --cov-report=xml
python -m pytest tests/ -v --cov=tabstack --cov-report=term-missing --cov-report=xml

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
Expand Down
3 changes: 2 additions & 1 deletion tabstack/_http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@ async def post(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[s

# Parse successful response
if response.content:
return response.json()
result: Dict[str, Any] = response.json()
return result
else:
return {}

Expand Down
3 changes: 2 additions & 1 deletion tabstack/_http_client_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@ def post(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, An

# Parse successful response
if response.content:
return response.json()
result: Dict[str, Any] = response.json()
return result
else:
return {}

Expand Down
16 changes: 16 additions & 0 deletions tabstack/_shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,21 @@ def build_automate_request(
task: str,
url: Optional[str] = None,
schema: Optional[Dict[str, Any]] = None,
data: Optional[Dict[str, Any]] = None,
guardrails: Optional[str] = None,
max_iterations: Optional[int] = None,
max_validation_attempts: Optional[int] = None,
) -> Dict[str, Any]:
"""Build request data for automation task.

Args:
task: Task description in natural language
url: Optional starting URL
schema: Optional JSON Schema for structured output
data: Optional JSON data for form filling or complex tasks
guardrails: Optional safety constraints for execution
max_iterations: Optional maximum task iterations (1-100, default: 50)
max_validation_attempts: Optional maximum validation attempts (1-10, default: 3)

Returns:
Request data dictionary
Expand All @@ -114,6 +122,14 @@ def build_automate_request(
request_data["url"] = url
if schema:
request_data["schema"] = schema
if data:
request_data["data"] = data
if guardrails:
request_data["guardrails"] = guardrails
if max_iterations is not None:
request_data["maxIterations"] = max_iterations
if max_validation_attempts is not None:
request_data["maxValidationAttempts"] = max_validation_attempts
return request_data


Expand Down
16 changes: 14 additions & 2 deletions tabstack/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ async def automate(
task: str,
url: Optional[str] = None,
schema: Optional[Dict[str, Any]] = None,
data: Optional[Dict[str, Any]] = None,
guardrails: Optional[str] = None,
max_iterations: Optional[int] = None,
max_validation_attempts: Optional[int] = None,
) -> AsyncIterator[AutomateEvent]:
"""Execute AI-powered browser automation task with streaming updates.

Expand All @@ -48,6 +52,10 @@ async def automate(
task: The task description in natural language
url: Optional starting URL for the task
schema: Optional JSON Schema for structured data extraction
data: Optional JSON data for form filling or complex tasks
guardrails: Optional safety constraints for execution
max_iterations: Optional maximum task iterations (1-100, default: 50)
max_validation_attempts: Optional maximum validation attempts (1-10, default: 3)

Yields:
AutomateEvent objects representing different stages of task execution
Expand All @@ -63,7 +71,9 @@ async def automate(
>>> async with Tabstack(api_key="your-key") as tabs:
... async for event in tabs.agent.automate(
... task="Find the top 3 trending repositories",
... url="https://github.com/trending"
... url="https://github.com/trending",
... guardrails="browse and extract only, don't star repos",
... max_iterations=20
... ):
... if event.type == "task:completed":
... print(f"Result: {event.data.final_answer}")
Expand Down Expand Up @@ -110,7 +120,9 @@ async def automate(
if schema:
validate_json_schema(schema)

request_data = build_automate_request(task, url, schema)
request_data = build_automate_request(
task, url, schema, data, guardrails, max_iterations, max_validation_attempts
)

# Stream the response and parse SSE events
current_event_type: Optional[str] = None
Expand Down
16 changes: 14 additions & 2 deletions tabstack/agent_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ def automate(
task: str,
url: Optional[str] = None,
schema: Optional[Dict[str, Any]] = None,
data: Optional[Dict[str, Any]] = None,
guardrails: Optional[str] = None,
max_iterations: Optional[int] = None,
max_validation_attempts: Optional[int] = None,
) -> Iterator[AutomateEvent]:
"""Execute AI-powered browser automation task with streaming updates.

Expand All @@ -48,6 +52,10 @@ def automate(
task: The task description in natural language
url: Optional starting URL for the task
schema: Optional JSON Schema for structured data extraction
data: Optional JSON data for form filling or complex tasks
guardrails: Optional safety constraints for execution
max_iterations: Optional maximum task iterations (1-100, default: 50)
max_validation_attempts: Optional maximum validation attempts (1-10, default: 3)

Yields:
AutomateEvent objects representing different stages of task execution
Expand All @@ -63,7 +71,9 @@ def automate(
>>> with TabstackSync(api_key="your-key") as tabs:
... for event in tabs.agent.automate(
... task="Find the top 3 trending repositories",
... url="https://github.com/trending"
... url="https://github.com/trending",
... guardrails="browse and extract only, don't star repos",
... max_iterations=20
... ):
... if event.type == "task:completed":
... print(f"Result: {event.data.final_answer}")
Expand Down Expand Up @@ -110,7 +120,9 @@ def automate(
if schema:
validate_json_schema(schema)

request_data = build_automate_request(task, url, schema)
request_data = build_automate_request(
task, url, schema, data, guardrails, max_iterations, max_validation_attempts
)

# Stream the response and parse SSE events
current_event_type: Optional[str] = None
Expand Down
91 changes: 91 additions & 0 deletions tests/test_shared.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""Tests for shared utilities."""

from tabstack._shared import build_automate_request


class TestBuildAutomateRequest:
"""Tests for build_automate_request function."""

def test_minimal_request(self) -> None:
"""Test building request with only task."""
result = build_automate_request(task="Find repositories")
assert result == {"task": "Find repositories"}

def test_with_url(self) -> None:
"""Test building request with url."""
result = build_automate_request(task="Find repositories", url="https://github.com/trending")
assert result == {
"task": "Find repositories",
"url": "https://github.com/trending",
}

def test_with_schema(self) -> None:
"""Test building request with schema."""
schema = {"type": "object", "properties": {"name": {"type": "string"}}}
result = build_automate_request(task="Extract data", schema=schema)
assert result == {"task": "Extract data", "schema": schema}

def test_with_data(self) -> None:
"""Test building request with data for form filling."""
data = {"name": "Alex", "email": "alex@example.com"}
result = build_automate_request(task="Fill form", data=data)
assert result == {"task": "Fill form", "data": data}

def test_with_guardrails(self) -> None:
"""Test building request with guardrails."""
result = build_automate_request(
task="Browse site", guardrails="read-only, no form submissions"
)
assert result == {
"task": "Browse site",
"guardrails": "read-only, no form submissions",
}

def test_with_max_iterations(self) -> None:
"""Test building request with max_iterations."""
result = build_automate_request(task="Complex task", max_iterations=20)
assert result == {"task": "Complex task", "maxIterations": 20}

def test_with_max_validation_attempts(self) -> None:
"""Test building request with max_validation_attempts."""
result = build_automate_request(task="Validate task", max_validation_attempts=5)
assert result == {"task": "Validate task", "maxValidationAttempts": 5}

def test_with_all_parameters(self) -> None:
"""Test building request with all parameters."""
schema = {"type": "object", "properties": {"items": {"type": "array"}}}
data = {"query": "python"}
result = build_automate_request(
task="Search and extract",
url="https://example.com",
schema=schema,
data=data,
guardrails="browse only",
max_iterations=30,
max_validation_attempts=2,
)
assert result == {
"task": "Search and extract",
"url": "https://example.com",
"schema": schema,
"data": data,
"guardrails": "browse only",
"maxIterations": 30,
"maxValidationAttempts": 2,
}

def test_max_iterations_zero_not_included(self) -> None:
"""Test that max_iterations=0 is included (it's valid, just not truthy)."""
# Note: 0 is a valid value but Python's 'if max_iterations is not None' handles this
result = build_automate_request(task="Test", max_iterations=0)
assert result == {"task": "Test", "maxIterations": 0}

def test_empty_string_guardrails_not_included(self) -> None:
"""Test that empty string guardrails is not included."""
result = build_automate_request(task="Test", guardrails="")
assert result == {"task": "Test"}

def test_empty_dict_data_not_included(self) -> None:
"""Test that empty dict data is not included."""
result = build_automate_request(task="Test", data={})
assert result == {"task": "Test"}