From 7009c3d5fd9f86adf25066822c22af9702fb7529 Mon Sep 17 00:00:00 2001 From: Travis Beauvais Date: Wed, 10 Dec 2025 15:02:08 -0800 Subject: [PATCH 1/4] Add automate params: guardrails, iterations --- tabstack/_shared.py | 16 ++++++++ tabstack/agent.py | 16 +++++++- tabstack/agent_sync.py | 16 +++++++- tests/test_shared.py | 93 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 tests/test_shared.py diff --git a/tabstack/_shared.py b/tabstack/_shared.py index 0f4db95..eedccd7 100644 --- a/tabstack/_shared.py +++ b/tabstack/_shared.py @@ -98,6 +98,10 @@ 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. @@ -105,6 +109,10 @@ def build_automate_request( 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 @@ -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 diff --git a/tabstack/agent.py b/tabstack/agent.py index f873b70..9a0675e 100644 --- a/tabstack/agent.py +++ b/tabstack/agent.py @@ -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. @@ -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 @@ -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}") @@ -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 diff --git a/tabstack/agent_sync.py b/tabstack/agent_sync.py index a1c47f9..10c5320 100644 --- a/tabstack/agent_sync.py +++ b/tabstack/agent_sync.py @@ -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. @@ -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 @@ -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}") @@ -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 diff --git a/tests/test_shared.py b/tests/test_shared.py new file mode 100644 index 0000000..f18e7be --- /dev/null +++ b/tests/test_shared.py @@ -0,0 +1,93 @@ +"""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"} From 08e3a0126970afd493a5ce7b75e5d0a13b7eda1a Mon Sep 17 00:00:00 2001 From: Travis Beauvais Date: Wed, 10 Dec 2025 15:09:31 -0800 Subject: [PATCH 2/4] Fix mypy return type errors --- tabstack/_http_client.py | 3 ++- tabstack/_http_client_sync.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tabstack/_http_client.py b/tabstack/_http_client.py index 27764d2..6a8e090 100644 --- a/tabstack/_http_client.py +++ b/tabstack/_http_client.py @@ -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 {} diff --git a/tabstack/_http_client_sync.py b/tabstack/_http_client_sync.py index ea2556d..444d793 100644 --- a/tabstack/_http_client_sync.py +++ b/tabstack/_http_client_sync.py @@ -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 {} From 6ed6921766338840121a6e6ede0e1850800513de Mon Sep 17 00:00:00 2001 From: Travis Beauvais Date: Wed, 10 Dec 2025 15:13:33 -0800 Subject: [PATCH 3/4] Format test_shared.py with ruff --- tests/test_shared.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_shared.py b/tests/test_shared.py index f18e7be..2a39903 100644 --- a/tests/test_shared.py +++ b/tests/test_shared.py @@ -13,9 +13,7 @@ def test_minimal_request(self) -> None: def test_with_url(self) -> None: """Test building request with url.""" - result = build_automate_request( - task="Find repositories", url="https://github.com/trending" - ) + result = build_automate_request(task="Find repositories", url="https://github.com/trending") assert result == { "task": "Find repositories", "url": "https://github.com/trending", From b33d98021a52b18e1d15720c25f05728fb50a8e9 Mon Sep 17 00:00:00 2001 From: Travis Beauvais Date: Wed, 10 Dec 2025 15:13:57 -0800 Subject: [PATCH 4/4] Fix CI: always install deps, use python -m --- .github/workflows/test.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fed316d..fb4e93c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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