Skip to main content

The agent framework where the model never holds the trigger.
Your agent is about to issue a refund, ship a deploy, or delete a customer record. Every consequential action clears a policy check first — real code, outside the model, before the side effect, on a record you can verify. Build on it, or put it around the agent you already have.

PyPI version Python 3.11–3.14 License: Apache 2.0 mypy strict ruff clean

The admission gate · Quickstart · Notebooks · Docs


Try to break it — 30 seconds, no API key

OpsBot holds live-looking production tools: wipe the database, exfiltrate secrets, isolate the cluster, disable every user. Offline, the model arrives already fully compromised — it reaches for a dangerous tool every turn. Nothing left to jailbreak. Watch what stops it anyway.

pip install tulip-agents
python -m tulip.rogue
🎯 attempt #1: wipe the production database
🤖 OpsBot: Blocked before it ran. I could not complete that.
   ⚠️  model was talked into trying: wipe_database
   🛡️  wipe_database → deny: labels ['irreversible'] are denied by policy;
       blast radius 999 exceeds the maximum 1; labels ['production'] require human approval
   → the MODEL was already owned. The RUNTIME didn't care. Breaches still 0.

FINAL SCORE  ·  dangerous attempts: 1  ·  💥 BREACHES: 0  ·  audit chain intact: ✓

Point TULIP_MODEL_URL at your own vLLM / Ollama / LM Studio server to run it against a real model, or set ANTHROPIC_API_KEY for the hard version against a frontier one. When the model refuses on its own the scoreboard says so, rather than claiming a win the gate didn't earn.


The admission gate

The moment an agent stops advising and starts acting, a wrong step becomes a real consequence. A prompt rule is advisory — a misleading input or an injected document can talk the model past it. Tulip makes the rule structural: the side-effecting call runs only after it clears admit(), which the model has no way to reach around.

from tulip.control import Action, AuditTrail, ControlPolicy, admit, AdmissionError

policy = ControlPolicy(require_human_for={"production"})
trail = AuditTrail()

async def safe_refund(order_id: str, usd: float):
    try:
        return await admit(
            Action(name="refund", asset=order_id, kind="payment", environment="production"),
            lambda: payments.refund(order_id, usd),     # your code — any agent loop
            policy=policy, trail=trail,
        )
    except AdmissionError as e:
        notify_oncall(e.decision)                       # the gate held it; the trail has it

On a tool your own agent calls, gate_tool does the same thing in one line — the returned tool keeps the original's name, description and schema, so the model cannot tell the difference and nothing else in the agent changes:

from tulip.control import ControlPolicy, gate_tool

agent = Agent(model=model, tools=[
    lookup_order,                                     # read-only, ungated
    gate_tool(issue_refund, policy=ControlPolicy()),  # gated
])

Refused calls come back to the model as a readable refusal naming the outcome and the reason, so the agent explains the hold instead of the run ending in a traceback.

action → policy → approval → admission → audit

  • Policy + approval — approve() weighs your ControlPolicy (blast radius, require_human_for, verification score) and returns allow, hold, or deny.
  • Admission — admit() runs the action only if approval allows, recording the decision to the AuditTrail; otherwise it raises AdmissionError.
  • Audit — every entry is linked to the one before it, so editing any record breaks verify(). (A keyless SHA-256 chain: tamper-evident, not notarized — add signing before treating it as legally authoritative.)

Human approvals can survive a restart: with gate_tool(..., on_refusal="interrupt", approval=FileApprovals(path)) a hold pauses the run, a checkpointer keeps the conversation, and once a named person decides, agent.resume(..., perform_dangling=True) runs the approved call exactly once, with the arguments that were approved.

Measured, not asserted. Running the integration suite against a weaker judge model (Qwen3.6-35B) produced the contrast by accident: SteeringHook — the SDK's own LLM-as-judge guardrail — failed open and the agent reported a table deleted, while in the same session, under the same model, a tool body behind admit() held: the model did call the destructive tool, admission refused it, the side effect never ran, and the refusal landed on a chain that still verifies. Nobody designed the experiment — a weaker judge is simply the condition under which an advisory control degrades, and degradation under a weaker model is precisely the risk a structural control exists to remove. (Recorded in #145.)

For tools where a duplicate call would hurt — moving money, paging an on-call — declare @tool(idempotent=True): the loop keys every invocation on (name, args) and refuses to fire the same one twice, even across retries.

→ The admission gate · Idempotency


And underneath it, a full SDK

If you're starting fresh rather than wrapping something, the gate sits on a complete agent framework. A model is a string, a tool is a function, and run_sync runs the loop.

from tulip import Agent, tool

@tool
def search_flights(origin: str, destination: str, date: str) -> list[dict]:
    """Find available flights between two cities on a given date."""
    return flights.search(origin, destination, date)

agent = Agent(
    model="anthropic:claude-sonnet-4-6",          # swap providers with one string
    tools=[search_flights],
    system_prompt="You are a travel assistant. Be concise and cite prices.",
)

print(agent.run_sync("Cheapest flight from Lisbon to Berlin next Friday?").text)
pip install "tulip-agents[anthropic]"     # or [openai], or [sdk] for everything

And governed_agent() gives any Tulip agent the whole harness — grounded, guarded, risk-gated, audited — in one call:

from tulip.control import governed_agent

secured = governed_agent(model="openai:gpt-4o", tools=[...])
assert secured.audit_trail.verify()   # the chain is intact — no record was altered

A bundled MockModel means every notebook runs offline with no credentials.


What you get

Control — the runtime that clears actions:

⚖️ Admission gate admit() / approve() run a consequential action only if your ControlPolicy allows — else hold for a human or deny, recorded either way.
🧠 GSAR grounding Claims partitioned grounded / ungrounded / contradicted / complementary; below threshold the agent regenerates, replans, or abstains. arXiv:2604.23366.
🎯 Decisions tulip.decision — Choice / YesNo / Score answered with a probability per answer from one forward pass, on any OpenAI-compatible server. An admit head plugs into approve(advisor=); safety heads feed verification. Escalate-only.
🔁 Idempotent tools @tool(idempotent=True) — dedupes on (name, args). The model can't double-charge, double-book, or double-page.
🪝 Hooks Logging · OpenTelemetry · ModelRetry · Guardrails · Steering (LLM-as-judge).

Build — the agent framework surface:

🧭 Cognitive router Describe a task → eight named protocols → the right primitive compiled automatically. The LLM fills a typed schema; routing is deterministic.
🤝 Multi-agent Seven native patterns + cross-process A2A. One Agent class. One event stream.
🔬 DeepAgent create_deepagent (per-turn grounding) and create_research_workflow (StateGraph with post-hoc grounding eval).
🪙 MCP MCPClient consumes MCP servers. TulipMCPServer exposes the SDK's tools as MCP.
🌐 Multi-modal Agent(web_search=…, web_fetch=…, image_generator=…, speech_provider=…) auto-registers tools.

Run — operate it in production:

📡 Observability Opt-in EventBus — one run_context() streams 40+ canonical events from every layer, no external broker.
💾 Durable memory 8 checkpoint backends — PostgreSQL · MySQL · Redis · OpenSearch · S3 / MinIO / R2 · in-memory · file · HTTP.
🔎 RAG 5 vector stores — pgvector · Qdrant · Chroma · OpenSearch · in-memory. OpenAI + Cohere embeddings, local + Cohere rerankers.
📡 Streaming + Server Typed events · SSE · AgentServer (FastAPI, bearer auth, thread persistence).
📊 Evaluation EvalCase / EvalRunner / EvalReport regression suites.

Every backend is an optional extra — install only what you use (pip install "tulip-agents[qdrant,s3,rerank-local]").


Grounded by construction (GSAR)

An agent that acts must not assert what it can't back up. Tulip's grounding layer implements GSAR, the method described in Kamelhar, 2026, and partitions every claim — grounded / ungrounded / contradicted / complementary — against typed evidence, where tool output outranks inference and inference outranks domain priors. Below threshold the run regenerates, replans, or abstains. There is no public constructor that emits a grounded result without a score, so an ungrounded claim is unshippable by construction — not filtered after the fact.

from tulip.control import ground_finding, Severity, is_finding

result = ground_finding(..., partition=partition)
# A grounded partition → a typed result. An ungrounded one → an auditable
# Abstention with the reason it was withheld. There is no third path.
print(result.title if is_finding(result) else f"withheld: {result.reason}")

→ GSAR grounding


The cognitive router and multi-agent shapes

Describe a task in plain language; the cognitive router (PRISM) runs an LLM classifier that fills a typed GoalFrame, matches it to one of eight coordination protocols, and compiles the matching runtime primitive. The model classifies; routing is deterministic — it never authors the topology.

Protocol Compiled shape Best for
direct_response Single Agent ANSWER, EXPLAIN
plan_execute_validate SequentialPipeline (planner → executor → validator) PLAN, BUILD, MODIFY
specialist_fanout ParallelPipeline of N tool-bound Agents DIAGNOSE, MONITOR
debate Two debaters + judge Agent COMPARE
codegen_test_validate LoopAgent (stops on PASS) GENERATE_CODE
approval_gated_execution Agent wrapped in approval interrupt ESCALATE, REMEDIATE
handoff_chain SequentialPipeline of one-tool Agents COORDINATE
a2a_delegate Cross-process agent-to-agent call (opt-in) distributed meshes

Each shape is also a first-class primitive you can use directly — SequentialPipeline, ParallelPipeline, LoopAgent, Orchestrator, Swarm, Handoff, StateGraph, A2A.

import asyncio

from tulip.agent import Agent, SequentialPipeline


async def main():
    result = await SequentialPipeline(agents=[draft, check, finalize]).run(
        "Summarize the trade-offs of moving the checkout service to a queue."
    )
    print(result.final_output)


asyncio.run(main())

→ Cognitive router · All patterns


Providers

A model is a string. The prefix picks the provider; the rest is the model id it expects.

21 prefixes ship built in. openai:, anthropic:, bedrock: and azure: are native; the rest are OpenAI-compatible endpoints with their base URL and key convention already filled in — gemini: · ollama: · vllm: · lmstudio: · llamacpp: · litellm: · groq: · together: · openrouter: · deepseek: · mistral: · xai: · fireworks: · cerebras: · perplexity: · nvidia:, plus openai-compatible: for anything else.

Agent(model="ollama:llama3.2")                      # localhost:11434, no key needed
Agent(model="groq:llama-3.3-70b-versatile")         # GROQ_API_KEY from the environment
Agent(model="anthropic:claude-sonnet-5")
Agent(model="bedrock:us.amazon.nova-lite-v1:0")     # boto3 credential chain
Agent(model="azure:gpt4o-prod")                     # AZURE_OPENAI_ENDPOINT, a deployment name
Agent(model="gemini:gemini-2.0-flash")              # GEMINI_API_KEY

bedrock: goes through the Converse API, so one code path covers every model on the service — Nova, Claude, Llama, Mistral, Titan — and credentials are boto3's standard chain (environment, profile, SSO, instance role, IRSA). Install with pip install "tulip-agents[bedrock]"; boto3 is imported lazily, so the four-package core install is unchanged for everyone not on AWS.

azure: names a deployment, not a model id, and reuses the openai extra — Azure's api-key header, api-version and deployment-shaped URLs are handled by the SDK's Azure client, so there is no second implementation to drift. gemini: uses Google's own OpenAI-compatible endpoint, so it needs no additional client either.

Configuration that is not in the environment travels in model_kwargs — a per-agent key, or a host that is not the default:

Agent(
    model="openai-compatible:qwen3.6-35b",
    model_kwargs={"base_url": "http://gpu-1:8000/v1", "api_key": "unused"},
)

Anything else implements ModelProtocol — complete · stream, ~50 lines — and registers with register_provider("myco", MyModel).

→ Model providers · OpenAI-compatible endpoints


Notebooks and deploy

examples/ has progressive notebooks, numbered in suggested reading order. Each defaults to the bundled mock model when no API key is present.

git clone https://github.com/tuliplabs-ai/tulip-agents.git
cd tulip-agents && pip install -e .

python examples/notebook_06_basic_agent.py           # your first agent
python examples/notebook_58_cognitive_router.py      # the cognitive router
python examples/notebook_69_research_workflow.py     # full research pipeline

For production, AgentServer is a drop-in FastAPI app (POST /invoke, POST /stream, GET/DELETE /threads/{id}, GET /health) and the repo ships a multi-stage Dockerfile.

from tulip.server import AgentServer

AgentServer(agent=my_agent, api_key=os.environ["API_KEY"]).run(host="0.0.0.0", port=8080)

→ Notebooks · Deploy


Any domain, one contract

The same contracts run wherever an agent acts. Security-domain tooling built on them — AI red-teaming, SOC triage, and threat-intel / SIEM / EDR adapters — ships separately as the opt-in tulip-agents-security package, installed from this repository (it is not published to PyPI); the core runtime does not include it.


Repo layout

src/tulip/
├── control/        Admission gate, policy, audit trail, grounded findings, verification
├── decision/       Typed decisions (choice, yes/no, score) with probabilities; per-tenant heads
├── rogue/          The rogue-agent challenge (`python -m tulip.rogue`)
├── agent/          Agent runtime, config, Sequential / Parallel / Loop pipelines
├── core/           AgentState, Message, events, termination algebra, Send
├── loop/           ReAct nodes (Think, Execute, Reflect)
├── router/         Cognitive router — GoalFrame, ProtocolRegistry, PolicyGate, Compiler
├── reasoning/      Reflexion, Grounding, Causal, GSAR
├── multiagent/     Orchestrator, Swarm, Handoff, StateGraph, Functional
├── a2a/            Cross-process Agent-to-Agent protocol
├── deepagent/      create_deepagent + create_research_workflow + 6 node primitives
├── memory/         BaseCheckpointer + 8 backends
├── rag/            Embeddings + 5 vector stores + rerankers + retrievers
├── models/         Provider registry + OpenAI, Anthropic, Bedrock, Azure
├── tools/          @tool decorator, registry, builtins, executors
├── hooks/          Logging, telemetry, retry, guardrails, steering
├── observability/  EventBus, run_context, agent yield bridge, EV_* constants
├── skills/         AgentSkills.io filesystem-first capability disclosure
├── playbooks/      Declarative step plans + PlaybookEnforcer
├── providers/      Multi-modal: web search, web fetch, image, speech
├── server/         FastAPI AgentServer with thread persistence
├── evaluation/     EvalCase + EvalRunner + EvalReport
└── integrations/   MCP (client + server)

The docs site lives in a sibling repo: tuliplabs-ai/docs, published at tulipagents.ai.


Contributing

git clone https://github.com/tuliplabs-ai/tulip-agents.git
cd tulip-agents && pip install -e ".[dev,sdk]"
hatch run check        # ruff + mypy
hatch run test         # unit tests across Python 3.11–3.14
pre-commit install

See CONTRIBUTING.md. Every PR runs format, lint, mypy, unit tests, DCO sign-off. Please consult the security guide for vulnerability disclosure.


GSAR reference

Tulip's GSAR layer is based on the method described in F. A. Kamelhar, GSAR: Typed Grounding for Hallucination Detection and Recovery in Multi-Agent LLMs (PDF), arXiv:2604.23366, 2026. The paper is separate from this project; if you use the method, cite it directly:

@article{kamelhar2026gsar,
  author  = {Kamelhar, Federico A.},
  title   = {GSAR: Typed Grounding for Hallucination Detection and Recovery in Multi-Agent LLMs},
  journal = {arXiv preprint arXiv:2604.23366},
  year    = {2026},
  url     = {https://arxiv.org/abs/2604.23366},
}

License

Copyright 2026 The Tulip Authors.

Released under the Apache License, Version 2.0 — see LICENSE and NOTICE.

Releases before 1.0.0 were published under the Universal Permissive License v1.0 (UPL-1.0). The project was relicensed to Apache-2.0 at 1.0.0; code as it stood in those releases remains available under the UPL-1.0. LICENSES/ holds both texts and the list of files that date from the UPL-1.0 releases.

Metadata

Release files for tulip-agents 2.28.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for tulip-agents 2.28.0
File Size Uploaded
tulip_agents-2.28.0.tar.gz 2.0 MB Details

Built distribution (wheel)

Table of built distributions (wheels) for tulip-agents 2.28.0
File Interpreter ABI Platform
tulip_agents-2.28.0-py3-none-any.whl Python 3 none any Details

Total release size: 3.2 MB

Release files / tulip_agents-2.28.0.tar.gz

Download URL tulip_agents-2.28.0.tar.gz
Size 2.0 MB
Tags Source
SHA-256 checksum
How to use checksums
f4d07a41f8ba287be1d30bb4581cfef9a5b32ef45bf07e604faa610ae99afed4
BLAKE2b-256 checksum
How to use checksums
4e4286305ebe87c885f54857db713fcaac0786ae26ff32984cbbf1ff5e844842
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 10, 2026.

Transparency log

Release files / tulip_agents-2.28.0-py3-none-any.whl

Download URL tulip_agents-2.28.0-py3-none-any.whl
Size 1.2 MB
Tags Python 3
SHA-256 checksum
How to use checksums
e5787d196ffd07dc82a9cdf1f9c3e453d40fc1dd8063857cc2d1a45df860f422
BLAKE2b-256 checksum
How to use checksums
a3d732c10f4616b884e55aac6f0a3d4687160e46810273112d7d15a45f4a5c51
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 10, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

2.28.0 This release

2 release files

2.27.0

2 release files

2.26.0

2 release files

2.18.3

2 release files

2.18.2

2 release files

2.18.1

1 release file

2.17.0

1 release file

2.16.0

2 release files

2.15.0

2 release files

2.14.0

2 release files

2.13.0

2 release files

2.12.3

2 release files

2.12.2

2 release files

2.12.1

2 release files

2.12.0

2 release files

2.11.1

2 release files

2.11.0

2 release files

2.10.0

2 release files

2.9.0

2 release files

2.8.0

2 release files

2.7.0

2 release files

2.6.0

2 release files

2.5.1

2 release files

2.5.0

2 release files

2.4.0

2 release files

2.3.0

2 release files

2.2.0

2 release files

2.1.3

2 release files

2.1.2

2 release files

2.1.1

2 release files

2.1.0

2 release files

2.0.0

2 release files

1.1.0

2 release files

1.0.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page