Autonomous agents are genuinely good at answering messy business questions. Give one an LLM and a set of tools, and it can query a database, dig through files, and build a chart to investigate why a metric moved. No fixed script required.

The problem is what happens when nobody’s watching the meter.

If a question is simple, you don’t need an agent. You write one SQL query and you’re done. But most real analytical questions aren’t simple. They’re ambiguous, open ended, and take a few rounds of trial and error before they resolve. An agent handles that the way a human analyst would if you turned them loose on a data warehouse with no supervision. It tries a query, gets a weird result, tries a different angle, hits a dead end, and tries again.

On a warehouse the size of BigQuery, that trial-and-error loop gets expensive fast. Add in the token costs of a long-context LLM reasoning through fifteen turns, and a single agent run can blow past your budget before anyone notices.

In this post, I’ll walk through how I built a cost-safe version of this kind of agent, using the Google Antigravity SDK and the Data Agent Kit (DAK) extension. Two guardrails do the work.

  1. A BigQuery scan cost guardrail. It dry-runs every query before it executes and blocks anything that would scan too much data.
  2. An LLM token spend guardrail. It pauses the agent for human approval once a session crosses a token budget.

Why autonomous analysis runs amok at scale

To see why this matters, it helps to look at how BigQuery bills, how agents actually reason, and where the two collide.

The columnar trap

A lot of people assume that adding a LIMIT 10 or a WHERE clause keeps a query cheap. In a columnar database like BigQuery, that assumption will bite you.

BigQuery charges by the bytes it reads from the columns you reference, across the whole table, not the rows you end up seeing. Run something like SELECT body FROM comments WHERE body LIKE '%pandas%' and it scans the entire body column for every row before it even gets to filtering. There's no row-level index to jump to matching rows, so evaluating that filter means reading every value in the column first. The LIMIT clause runs after that scan is already paid for, so it doesn't save you anything.

The only real lever is filtering on a partitioned column, which restricts the query to specific physical blocks like a date range, or a clustered column, which prunes blocks based on sorted keys.

Why agentic exploration is so inefficient

A human analyst scopes a query carefully before running it. An agent doesn’t work that way. It loops.

For an open-ended problem, the path usually looks like this. It runs a few queries to discover table structures and columns. It tries different combinations of joins, filters, and aggregations to test its hypotheses, and when it hits an empty result or a syntax error, it rewrites and tries again. It runs heavy string scans, LIKE '%error%' across a text column, to pull out logs or comments. And when it needs to merge sources, it runs wide multi-key joins that shuffle data across worker slots.

Here’s where it gets real. In a production environment with tables in the hundreds of terabytes, a single on-demand query scanning 100 TB of one column costs $625 at standard $6.25/TB pricing. Stack a few window functions and nested joins on top and you’re triggering shuffle operations that move gigabytes between worker slots. If you’re on capacity-based billing instead of on-demand, those same queries burn through slot-hours and degrade everyone else’s performance.

An agent running fifteen turns of unoptimized exploratory queries on TB-scale tables can rack up thousands of dollars in a single run. That’s not a hypothetical. That’s what happens the first time you point one of these things at a real warehouse without a guardrail.

Investigating dev sentiment and tech stack health

To build and test this, I used the Stack Overflow dataset from BigQuery Public datasets.

Here’s the question I tried to get the answer to:

Developer engagement metrics for Python data science libraries (pandas, numpy) on Stack Overflow dropped in late 2023. Answer rates and comment volumes fell. Did that correlate with a specific library release? Was it a boycott, or something more transient?

That’s a genuinely open-ended problem, and answering it takes a few steps. First, verify the trend actually happened, and check whether it was unique to pandas and numpy or part of a broader Stack Overflow slowdown (a seasonal dip, or the rise of ChatGPT, would show up across all topics). Second, cross-reference the comment trend with PyPI download stats to see whether people actually stopped installing the library, or just stopped needing to ask about it, say, after pandas 2.1 shipped in September 2023. Third, run text searches on the comments themselves to find the specific error messages or breaking changes people were complaining about, like the PyArrow dependency changes.

To run this without setting up a bunch of infrastructure, the agent queries across two systems. BigQuery holds the Stack Overflow public dataset (bigquery-public-data.stackoverflow), hundreds of gigabytes of partitioned post and comment data. A local CSV holds monthly PyPI download rates for pandas and numpy.

Wiring up Data Agent Kit and Antigravity

Data Agent Kit (DAK) runs as an extension inside the Antigravity IDE or CLI, and it handles the data-connection plumbing so you don’t have to.

It exposes database tools as MCP servers, bigquery_remote being the one I used here, which run remotely in GCP and talk directly to your data in BigQuery. It also ships filesystem-based Agent Skills for performing various data operations, such as data cleaning, building data pipelines, querying data etc., using Google Cloud services and standard tools such as Python and Notebooks. Skills are pre-packaged directories with standard SKILL.md instruction files and references.

Once you install DAK in your IDE, you configure it with the Google Cloud Project you want to work with. The MCP tools and skills get injected into the agent’s workspace automatically. I didn’t have to write connection pooling, manage GCP credentials by hand, or wire up custom Python imports for BigQuery access.

The Antigravity SDK sits on top of that as the agent runtime. It’s where I write the governance logic, the lifecycle hooks that intercept and control whatever tools DAK injects. That’s where the guardrails live.

from google.antigravity.hooks import policy

# 2. Config is clean: the IDE automatically injects DAK tools and skills
config = LocalAgentConfig(
    system_instructions="You are an autonomous data investigator...",
    policies=[
        policy.deny("search_web")           # Force the agent to use BigQuery instead of search shortcuts
    ],
    hooks=[
        bq_cost_guardrail_hook,             # Intercepts the injected BQ tool for dry-run check
        check_token_budget_hook             # Intercepts chat turns for budget validation
    ]
)
Enter fullscreen mode Exit fullscreen mode

Splitting the data platform layer (DAK) from the agent harness layer (Antigravity SDK) is what makes this clean. The guardrails live as harness hooks, and DAK just handles the database connections underneath.

Guardrail 1: the BigQuery scan cost guardrail

This one intercepts SQL queries with the Antigravity SDK’s @hooks.pre_tool_call_decide hook.

Before a query runs, I do a dry run. It’s a free operation, and it tells you exactly how many bytes the query will scan. If that number blows past the budget, the call gets rejected and the agent gets a message back telling it why, so it can rewrite the query with a proper partition filter.

One thing worth saying up front. The 10 GB limit below is small on purpose, small enough that you’ll actually see the guardrail trigger and self-correct within a couple of turns. Your real limit depends on your table sizes, your pricing tier, and what you’re actually willing to spend on a single exploratory query. Treat it as a placeholder and set your own before you point this at a real workload.

from google.cloud import bigquery
from google.antigravity import types
from google.antigravity.hooks import hooks

# Cost settings
SINGLE_QUERY_SCAN_LIMIT_GB = 10.0  # 10 GB limit per query
BQ_PRICE_PER_TB = 6.25             # Standard BigQuery on-demand pricing

def estimate_bq_query_cost(sql: str) -> tuple[float, float]:
    """Perform a dry run to estimate the query scan size and cost."""
    client = bigquery.Client()
    job_config = bigquery.QueryJobConfig(dry_run=True, use_query_cache=False)
    try:
        query_job = client.query(sql, job_config=job_config)
        bytes_scanned = query_job.total_bytes_processed
        gb_scanned = bytes_scanned / (1024 ** 3)
        cost = (bytes_scanned / (1024 ** 4)) * BQ_PRICE_PER_TB
        return gb_scanned, cost
    except Exception:
        # If the dry run fails (e.g. syntax error), let the actual query run fail naturally
        return 0.0, 0.0

@hooks.pre_tool_call_decide
async def bq_cost_guardrail_hook(data: types.ToolCall) -> types.HookResult:
    # Intercept BigQuery tool execution from DAK MCP tools
    if data.name in ["execute_sql", "execute_sql_readonly", "query_bigquery"]:
        sql = data.args.get("sql") or data.args.get("query")
        if not sql:
            return types.HookResult(allow=True)

        gb_scanned, cost = estimate_bq_query_cost(sql)
        # Print status so users running this in a terminal can see the guardrail in action!
        print(f"\n[Guardrail Check] Intercepted Query: {sql[:120].strip()}...")
        print(f"[Guardrail Check] Estimated Scan: {gb_scanned:.2f} GB (Estimated Cost: ~${cost:.4f})")

        if gb_scanned > SINGLE_QUERY_SCAN_LIMIT_GB:
            error_message = (
                f"Query rejected by Cost Guardrail! "
                f"This query is estimated to scan {gb_scanned:.2f} GB of data (costing ~${cost:.4f}), "
                f"which exceeds your safety limit of {SINGLE_QUERY_SCAN_LIMIT_GB} GB per query. "
                f"Please rewrite your query to limit scan costs by filtering on partitioned/clustered "
                f"columns (e.g., creation_date) and avoiding SELECT *."
            )
            print(f"❌ [Guardrail Triggered] BLOCKING query scan ({gb_scanned:.2f} GB > {SINGLE_QUERY_SCAN_LIMIT_GB} GB).")
            # Block the tool execution and send the guidance back to the agent's context
            return types.HookResult(allow=False, message=error_message)
        else:
            print(f"✅ [Guardrail Approved] Query scan approved ({gb_scanned:.2f} GB <= {SINGLE_QUERY_SCAN_LIMIT_GB} GB).")

    return types.HookResult(allow=True)

Enter fullscreen mode Exit fullscreen mode

Here is the visual flow of how this cost interceptor and the agent self-correction loop function:

The Agent Self-Correction Loop

When the agent receives this rejection as the “tool result,” its LLM reads the guidance, understands the partition requirement, and automatically rewrites the query. Here is what the self-correction log looks like when the agent attempts a raw scan:

[Agent Turn 1]
Thinking: I need to analyze comment text containing "pandas". Let me query the comments table.
Tool Call: execute_sql_readonly(sql="SELECT text FROM `bigquery-public-data.stackoverflow.comments` WHERE text LIKE '%pandas%'")

[Guardrail Triggered]
System Hook: Query rejected by Cost Guardrail! This query is estimated to scan 14.09 GB of data (costing ~$0.09), which exceeds your safety limit of 10.0 GB. Please rewrite your query to filter on partitioned columns (e.g., creation_date).

[Agent Turn 2]
Thinking: Ah, my query was blocked because it scans the entire comments table. I should restrict the scan size by adding a date filter. The 'comments' table is partitioned by 'creation_date'.
Tool Call: execute_sql_readonly(sql="SELECT text FROM `bigquery-public-data.stackoverflow.comments` WHERE creation_date >= '2022-01-01' AND creation_date < '2022-02-01' AND text LIKE '%pandas%'")

[Guardrail Approved]
System Hook: Query estimated to scan 1.8 GB. Approved.

Enter fullscreen mode Exit fullscreen mode

Guardrail 2: the LLM token spend guardrail

The BigQuery guardrail watches the data layer. This one watches the reasoning budget.

Autonomous agents run in a loop, and every turn the conversation history grows, more prompts, more tool calls, more results, more reasoning steps. Token usage climbs with it, and so does your API bill.

Because the Antigravity SDK manages the agent’s internal reasoning loop asynchronously inside a single await agent.chat() call, you can't just check usage in a manual loop. You need an SDK hook. I built a stateful manager that checks the conversation's total_usage before each turn and asks a human for confirmation if the budget is breached.

import asyncio
from google.antigravity import Agent, LocalAgentConfig, types
from google.antigravity.hooks import hooks

class SessionGuardrail:
    def __init__(self, token_budget: int):
        self.token_budget = token_budget
        self.agent = None

    def register(self, agent: Agent):
        """Bind the agent reference to the guardrail manager."""
        self.agent = agent

    async def check_token_budget(self, data: str) -> types.HookResult:
        """Intercept before a chat turn starts to enforce the token budget."""
        if self.agent and self.agent.conversation:
            usage = self.agent.conversation.total_usage
            # Show active token monitoring in the terminal on every turn
            print(f"[Token Monitor] Session token count: {usage.total_token_count:,} / {self.token_budget:,}")

            if usage.total_token_count > self.token_budget:
                # Prompt the user asynchronously for authorization
                print(f"\n[Budget Alert] Cumulative token usage is {usage.total_token_count}. Budget is {self.token_budget}.")
                confirm = await asyncio.to_thread(input, "Authorize additional tokens? (y/n): ")

                if confirm.strip().lower() != 'y':
                    return types.HookResult(
                        allow=False,
                        message=f"Session suspended: Token budget of {self.token_budget} exceeded."
                    )
        return types.HookResult(allow=True)
Enter fullscreen mode Exit fullscreen mode

One thing I want to call out here:

Using input() wrapped in asyncio.to_thread is a fast way to demonstrate human-in-the-loop validation from a local terminal, but it won't hold up in production. Blocking stdin reads can hang the event loop or throw an EOFError in a web backend, a CI runner, or a notebook. For anything real, swap the CLI prompt for an async request to a Slack webhook, a PagerDuty trigger, or a queue that resolves when someone clicks approve.

Running the safe agent loop

Now it all comes together in one config. The token budget here is another demo value. 150,000 tokens is enough to watch the guardrail trigger without waiting forever, not a number to carry into production. Set yours based on your actual session lengths and what a runaway session would cost you.

import asyncio
from google.antigravity import Agent, LocalAgentConfig
from google.antigravity.hooks import hooks, policy

# Global instance of the guardrail manager to hold state
guardrail = SessionGuardrail(token_budget=150_000)

@hooks.pre_turn
async def check_token_budget_hook(data: str) -> types.HookResult:
    return await guardrail.check_token_budget(data)

async def run_safe_investigation():
    # 1. Register cost guardrail hooks — DAK tools and skills are injected automatically by the IDE
    config = LocalAgentConfig(
        system_instructions=(
            "You are an autonomous data investigator. "
            "When outputting your reasoning, steps, or planned actions, you MUST format them clearly "
            "with newlines, bullet points, and spacing so they are clean and readable in a terminal console. "
            "Do not merge your planned actions into a single dense paragraph."
        ),
        policies=[
            policy.deny("search_web")     # Force the agent to use BigQuery instead of search shortcuts
        ],
        hooks=[
            bq_cost_guardrail_hook,       # Intercepts SQL queries
            check_token_budget_hook       # Intercepts chat turns
        ]
    )

    # 3. Execute the agent
    async with Agent(config) as agent:
        guardrail.register(agent)  # Give the manager access to the running agent

        user_query = (
            "You MUST run a live SQL query (using execute_sql or query_bigquery) to search the Stack Overflow "
            "comments table for pandas comments in Q4 2023, even if you think the table is unpartitioned. "
            "Use the standard file writing tools (like write_file) to save the final markdown report as "
            "'output/analysis_results.md' and the script as 'output/analyze_pandas.py' in the workspace. "
            "Do not use the save_artifact tool."
        )
        response = await agent.chat(user_query)
        print(await response.text())

if __name__ == "__main__":
    asyncio.run(run_safe_investigation())
Enter fullscreen mode Exit fullscreen mode

Getting your hands dirty

Ready to run this yourself? Here’s the walkthrough.

Step 1: clone the repo and look around

git clone https://github.com/sireeshapulipati/guarding-the-till.git
cd guarding-the-till
Enter fullscreen mode Exit fullscreen mode

The directory splits into two clean categories. The inputs, what you actually need to run it, are agent.py (setup, hooks, and the execution loop), requirements.txt, data/pypi_downloads.csv (monthly PyPI download stats for pandas and numpy), and .env.example. The outputs are pre-generated reference examples. output/analysis_results_example.md is the full markdown report from my run, and output/analyze_pandas_example.py is a standalone cost-guarded script the agent wrote to automate this in the future.

Step 2: sort out the GCP prerequisites

Before you boot the agent, make sure your terminal has access to Google Cloud. You need a project with the BigQuery API enabled, and you need to authenticate with Application Default Credentials.

gcloud auth application-default login

Enter fullscreen mode Exit fullscreen mode

You’ve got two options here, and the .env.example file lays both out. Authenticate with ADC like above and the Antigravity SDK picks up your active GCP context automatically, hooking straight into Vertex AI's Gemini models, as long as the Gemini for Google Cloud API is enabled on your project. Or skip GCP auth entirely and drop a Gemini API key straight into .env instead. Either path works, pick whichever fits how you're already set up.

Step 3: spin up your Python environment

python -m venv .venv
source .venv/bin/activate  # On Windows, use: .venv\Scripts\activate
pip install -r requirements.txt

Enter fullscreen mode Exit fullscreen mode

Step 4: configure your environment variables

cp .env.example .env

Enter fullscreen mode Exit fullscreen mode

Open .env and swap your_gcp_project_id_here for your actual GCP project ID.

Step 5: let it run

python agent.py

Enter fullscreen mode Exit fullscreen mode

Watch the logs. The agent starts up, formulates a plan, and immediately goes for the comments table. Agents are non-deterministic, so your exact run will vary depending on the prompt, but here’s roughly what happens.

The session opens with the token monitor reporting the starting count, something like [Token Monitor] Session token count: 0 / 150,000. This demo wraps in a single turn, so that's the only time you'll see it print. In a longer run with several self-correction turns, it fires at the start of each one, printing the cumulative count and pausing for approval once you cross budget. From there the agent reads the local files and narrates its plan, inspect the schema, then query Stack Overflow comments in BigQuery.

Then it tries the raw query, and the guardrail catches it before it runs. A dry run against the unpartitioned comments table comes back at 14.09 GB, over the 10 GB limit, so the hook blocks it and hands the rejection back as feedback. The agent reads that, realizes it needs the creation_date partition filter, rewrites the query, and resubmits. This time the scan comes in at 1.8 GB and gets approved.

Once the analysis wraps, the agent reaches for its file tools and writes the results straight into ./output/, a markdown report as analysis_results.md and a standalone script as analyze_pandas.py

Interpreting the results

Here is what the agent found:

Month Pandas PyPI Downloads Download MoM % Pandas SO Comment Count SO MoM %
Oct 2023 150,354,240 Baseline 6,210 Baseline
Nov 2023 147,923,892 -1.62% 5,845 -5.88%
Dec 2023 151,553,549 +2.45% 5,420 -7.27%

The agent calculated a Pearson Correlation Coefficient of $r \approx -0.3654$.

This is a weak-to-moderate negative correlation. Under normal circumstances, you would expect forum questions to rise as library installations rise. Instead, we see:

  • Installations increased: PyPI downloads reached their highest volume of the quarter in December (151.5M downloads).
  • Stack Overflow comments decreased: Stack Overflow comments mentioning pandas steadily declined month-over-month, dropping from 6,210 in October to 5,420 in December.

This divergence is likely driven by seasonality. In December, human developers take vacations and stop posting questions or answers on public forums, leading to a dip in active comments. Meanwhile, automated build systems, CI/CD runners, and deployment scripts continue pulling the package continuously, keeping the download numbers high.

What’s the standalone script for?

The agent also wrote output/analyze_pandas_example.py as part of its delivery. If you want to rerun this analysis every month in a production pipeline, an Airflow DAG, a cron job, you don't need to run the slow, non-deterministic agent loop again. Just run this.

python output/analyze_pandas_example.py

Enter fullscreen mode Exit fullscreen mode

It’s a fast, deterministic ETL pipeline. It queries BigQuery, calculates the correlation, and spits out a fresh markdown report, no LLM reasoning layer involved, while keeping the exact same dry-run safety guardrails.

What I took away from this

These two guardrails do two different jobs. The BigQuery dry-run watches the data layer and stops runaway scan bills before they happen. The session token guardrail watches the reasoning layer and caps what the LLM itself costs you. Together they give the agent a bounded sandbox to actually explore in.

The semantic error feedback is what actually makes this useful. Instead of a fatal exception that kills the run, the scan guardrail hands the LLM a plain-language explanation of what went wrong. That turns a runtime constraint into something the agent can actually act on, and it rewrites its own query instead of just failing.

And wrapping the token budget check in a non-blocking async hook means the human-in-the-loop step doesn’t freeze the rest of the system while it waits for someone to click approve.

If you’re building agents that need to explore large, cross-system datasets, this is the difference between an agent you can trust with a warehouse and one you have to babysit.