LangChain and Antigravity Pipelines, Designed Backwards from Permissions, Failures, and Cost
Design pipelines starting from where they resume after a crash, not from how the chains connect. With permission tiers, checkpointing, and measured before-and-after numbers from my own runs.
The first time I ran an asset-classification pipeline overnight for one of my wallpaper apps, I woke up to a log that simply stopped. Roughly half of a few thousand images had been processed. Rate limiting was the trigger, but that wasn't the real problem. The real problem was that nothing had recorded how far it got.
So I ran the whole thing again, and paid twice for the same images.
Since then, I decide one thing before I decide how the chains connect: where does this pipeline resume when it dies halfway through. LangChain supplies the composable components. Antigravity supplies the orchestration layer that runs them alongside a human. What sits between them are three decisions this article is really about — the permission boundary, the record of failure, and the visibility of cost.
We'll walk through composable chain design, memory management, routing, and resilience, then finish in the territory the official docs tend to skip: permission tiers and cost measured from real runs.
Understanding AI Pipeline Architecture
The Pipeline Abstraction
A pipeline isn't simply a chain of LLM calls. It's a composition of specialized components working in concert:
**Configuration-First Design:** Store all pipeline parameters in centralized configs. This enables experimentation—swap temperature, memory types, or retrieval strategies without touching code.
**Memory and Cost Implications:** Each RAG retrieval adds latency and costs. Use relevance thresholds aggressively, and implement caching for frequently retrieved contexts. Never over-retrieve.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A permission model that splits agent authority by whether the side effect can be undone, not by how scary it feels
✦Measured before-and-after numbers for reprocessed items and API calls once checkpointing and caching were added
✦A complete resumable pipeline built on nothing but SQLite
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Production pipelines require comprehensive testing strategies:
# tests/test_pipeline.pyimport pytestimport asynciofrom unittest.mock import AsyncMock, MagicMock@pytest.mark.asyncioasync def test_pipeline_happy_path(): """Test successful pipeline execution""" config = PipelineConfig() pipeline = AIAssistantPipeline(config) result = await pipeline.execute( "What is machine learning?", session_id="test-session-1" ) assert result["status"] == "success" assert "response" in result assert result["metadata"]["validation_timestamp"]@pytest.mark.asyncioasync def test_pipeline_validation_failure(): """Test handling of invalid input""" pipeline = AIAssistantPipeline(PipelineConfig()) result = await pipeline.execute( "x", # Too short session_id="test-session-2" ) assert result["status"] == "error" assert "too short" in result["error"].lower()@pytest.mark.asyncioasync def test_circuit_breaker_opens(): """Test circuit breaker behavior under failures""" breaker = CircuitBreaker(failure_threshold=3, recovery_timeout=1) # First 3 failures for i in range(3): with pytest.raises(RuntimeError): await breaker.execute(AsyncMock(side_effect=RuntimeError("Failure"))) # Circuit should be open assert breaker.state == CircuitBreaker.State.OPEN with pytest.raises(RuntimeError, match="Circuit breaker is OPEN"): await breaker.execute(AsyncMock())@pytest.mark.asyncio@pytest.mark.benchmarkasync def test_pipeline_load(benchmark): """Benchmark pipeline under load""" pipeline = AIAssistantPipeline(PipelineConfig()) async def run_pipeline(): return await pipeline.execute("Test query", "session-load-test") # Simulate 100 concurrent requests await asyncio.gather(*[run_pipeline() for _ in range(100)]) # Check performance assert pipeline.total_requests == 100 assert pipeline.failed_requests < 5 # Allow <5% failures
How Much Execution Authority Should an Agent Get
Once a pipeline starts acting on its own, the center of gravity shifts from how do these steps connect to what is this thing allowed to do.
Antigravity v2.2.1 (released 2026-06-25) introduced a unified permission model that lets you declare an agent's operating scope explicitly. I treat it as more than a safety feature — it's the vocabulary for pipeline design. And I draw the tiers by reversibility of the side effect, not by category of function.
Tier
Example operations
Reversible?
Approval
L1 Observe
Search, file reads, metrics fetch
Fully (no side effect)
None
L2 Reversible write
Commit to a branch, cache update, save a draft
Can be undone
Review after the fact
L3 Irreversible
Production deploy, charging a card, sending mail, DB DELETE
Cannot be undone
Human approval first
Draw the line at "is this dangerous" and every new tool becomes a fresh judgment call. Draw it at reversibility and the answer is determined the moment you write the function.
Here is how I enforce it at the LangChain tool layer. Every tool declares its tier, and an L3 tool cannot run without passing through an approval callback.
# pipeline/permissions.pyfrom enum import IntEnumfrom dataclasses import dataclassfrom typing import Callable, Optional, Anyimport logginglogger = logging.getLogger(__name__)class Tier(IntEnum): OBSERVE = 1 # no side effects REVERSIBLE = 2 # writes that can be undone IRREVERSIBLE = 3 # writes that cannotclass PermissionDenied(RuntimeError): """The operation was not approved."""@dataclassclass PermissionPolicy: """Highest tier allowed to run without a human in the loop.""" auto_max: Tier = Tier.REVERSIBLE approve: Optional[Callable[[str, dict], bool]] = None def check(self, tool_name: str, tier: Tier, payload: dict) -> None: if tier <= self.auto_max: logger.info("auto-approved", extra={"tool": tool_name, "tier": int(tier)}) return if self.approve is None: raise PermissionDenied(f"{tool_name}: no approval handler for L{int(tier)}") if not self.approve(tool_name, payload): raise PermissionDenied(f"{tool_name}: human approval was not granted") logger.warning("human-approved", extra={"tool": tool_name, "tier": int(tier)})def guarded(tier: Tier, policy: PermissionPolicy): """Bind a permission tier to a tool function.""" def wrap(fn: Callable[..., Any]) -> Callable[..., Any]: def inner(**kwargs: Any) -> Any: policy.check(fn.__name__, tier, kwargs) return fn(**kwargs) inner.__name__ = fn.__name__ inner.__doc__ = fn.__doc__ inner.permission_tier = tier # type: ignore[attr-defined] return inner return wrap
On the calling side, forgetting to declare a tier is not an option — an undeclared function never becomes a tool.
# pipeline/tools.pyfrom langchain_core.tools import StructuredToolfrom pipeline.permissions import Tier, PermissionPolicy, guardedpolicy = PermissionPolicy( auto_max=Tier.REVERSIBLE, approve=lambda name, payload: input(f"Run {name}? {payload} [y/N] ") == "y",)@guarded(Tier.OBSERVE, policy)def search_docs(query: str) -> str: """Search the docs and return the matching passage.""" return retriever.invoke(query)[0].page_content@guarded(Tier.IRREVERSIBLE, policy)def publish_release(tag: str) -> str: """Publish a release to production.""" return deploy_client.publish(tag)def as_tools(*fns) -> list[StructuredTool]: for fn in fns: if not hasattr(fn, "permission_tier"): raise ValueError(f"{fn.__name__} has no declared permission tier") return [StructuredTool.from_function(fn) for fn in fns]
Why a decorator rather than a prompt? I tried the prompt route — "please confirm before anything destructive." That is an instruction, not a constraint. Constraints belong in code, or they evaporate the day the model changes underneath you.
Measure the Failures and the Cost Before You Choose an Architecture
Back to that overnight run. What it lacked wasn't retries. It lacked a checkpoint.
A retry repeats one call. It has no memory of how far the run got.
Checkpointing needs no infrastructure worth the name. SQLite is enough.
# pipeline/checkpoint.pyimport sqlite3import jsonfrom typing import Iterable, Iterator, AnySCHEMA = """CREATE TABLE IF NOT EXISTS progress ( run_id TEXT NOT NULL, item_key TEXT NOT NULL, result TEXT, PRIMARY KEY (run_id, item_key));"""class Checkpoint: def __init__(self, path: str = "pipeline_state.db") -> None: self.conn = sqlite3.connect(path, isolation_level=None) self.conn.execute("PRAGMA journal_mode=WAL") self.conn.executescript(SCHEMA) def done_keys(self, run_id: str) -> set[str]: cur = self.conn.execute( "SELECT item_key FROM progress WHERE run_id = ?", (run_id,) ) return {row[0] for row in cur} def record(self, run_id: str, item_key: str, result: Any) -> None: self.conn.execute( "INSERT OR REPLACE INTO progress VALUES (?, ?, ?)", (run_id, item_key, json.dumps(result, ensure_ascii=False)), )def resumable( run_id: str, items: Iterable[tuple[str, Any]], ckpt: Checkpoint,) -> Iterator[tuple[str, Any]]: """Yield only the items this run hasn't finished yet.""" already = ckpt.done_keys(run_id) for key, payload in items: if key in already: continue yield key, payload
The caller does nothing clever. It records each result as it lands.
# pipeline/run.pyfrom pipeline.checkpoint import Checkpoint, resumableckpt = Checkpoint()RUN_ID = "wallpaper-classify-2026-03"for key, image in resumable(RUN_ID, iter_assets(), ckpt): try: result = classification_chain.invoke({"image": image}) except RateLimitError: # Whatever was recorded is skipped on the next run raise ckpt.record(RUN_ID, key, result)
INSERT OR REPLACE keeps a replayed key from corrupting anything. Mix in a non-idempotent write and resumption becomes the source of the next outage.
Here is what I measured classifying a few thousand assets on my own machine. Same input set, same model, same rate limit, three configurations — each killed once mid-run and then restarted.
Configuration
Items reprocessed after the kill
API calls on resume
Extra time to finish
Retries only
3,200 (everything)
3,200
~48 min
+ Checkpointing
~180
180
~3 min
+ Embedding cache
~180
62
~1 min
The absolute numbers travel poorly between environments. The ratios are the point. Checkpointing turns the cost of resuming into roughly a constant; caching then shaves what remains. Add the cache first and a crash still costs you the whole night. Checkpointing pays first.
I learned that ordering by watching my cost estimates miss. Raising cache hit rates is satisfying work with visible progress. But as long as the nightly batch dies and reruns from zero, none of that satisfaction shows up on the invoice.
Talk about pipeline design as "choosing an architecture" and the diagrams arrive first. What actually moved the needle for me was plainer than that.
Where does this resume when it dies.
Can that operation be undone.
What did that single item cost.
Get to a state where you can answer those three, and the routing and memory optimizations that follow stop generating rework. Done in the other order — as I did — you end up with a beautifully composed chain that you rerun from scratch every morning.
So here's the concrete next step: drop a Checkpoint into the pipeline you're running today, then kill it on purpose halfway through. If it resumes in minutes, everything you optimize afterward compounds. If it takes tens of minutes, the chain composition was never the thing to fix.
Thank you for reading — I hope some of this proves useful in your own runs.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.