Metadata-Version: 2.4
Name: pliuz
Version: 0.3.0
Summary: Human-in-the-loop approval gates for AI agents â€” pause function calls for human review, then resume or abort.
Project-URL: Homepage, https://pliuz.com
Project-URL: Repository, https://github.com/pliuz/pliuz-py
Project-URL: Issues, https://github.com/pliuz/pliuz-py/issues
Project-URL: Documentation, https://pliuz.com/docs
Project-URL: Changelog, https://github.com/pliuz/pliuz-py/blob/main/CHANGELOG.md
Author: Pliuz
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: agents,ai,approval,audit,guardrails,human-in-the-loop,llm
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx<1.0,>=0.27
Requires-Dist: pydantic<3.0,>=2.7
Requires-Dist: typing-extensions>=4.10; python_version < '3.12'
Provides-Extra: claude-agent
Requires-Dist: claude-agent-sdk<1.0,>=0.2; extra == 'claude-agent'
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == 'dev'
Requires-Dist: mypy>=1.11; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest-httpx>=0.32; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: pyyaml>=6.0; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Requires-Dist: ruff>=0.7; extra == 'dev'
Requires-Dist: twine>=5.1; extra == 'dev'
Requires-Dist: types-pyyaml>=6.0; extra == 'dev'
Provides-Extra: langchain
Requires-Dist: langchain-core<1.0,>=0.3; extra == 'langchain'
Provides-Extra: openai-agents
Requires-Dist: openai-agents<1.0,>=0.17; extra == 'openai-agents'
Description-Content-Type: text/markdown

# pliuz — Python SDK

[![PyPI](https://img.shields.io/pypi/v/pliuz)](https://pypi.org/project/pliuz/)
[![Python](https://img.shields.io/pypi/pyversions/pliuz)](https://pypi.org/project/pliuz/)
[![License](https://img.shields.io/pypi/l/pliuz)](https://github.com/pliuz/pliuz-py/blob/main/sdk-python/LICENSE)

> **Human-in-the-loop approval gates for AI agents.** Wrap any function in `@gated` to pause execution, route the call to a human approver via Pliuz, then resume — or abort — based on the decision. Every call is audited.

```python
from pliuz import gated

@gated(policy="refund", redact=["customer.ssn"])
def issue_refund(customer_id: str, amount_cents: int) -> dict:
    return stripe.refunds.create(customer=customer_id, amount=amount_cents)

# Pauses. A human gets a Slack card. They click approve.
# Then your code runs — and Pliuz records the outcome.
result = issue_refund("cus_123", 5000)
```

---

## Install

```bash
pip install pliuz
# or, with a framework adapter
pip install 'pliuz[langchain]'
pip install 'pliuz[claude-agent]'    # Anthropic Claude Agent SDK
pip install 'pliuz[openai-agents]'   # OpenAI Agents SDK
```

Requires Python ≥ 3.10.

## Quickstart

### 1. Get an API key

Sign up at [pliuz.com](https://pliuz.com/signup), create an agent in the dashboard, copy the key.

### 2. Set the env var

```bash
export PLIUZ_API_KEY=pli_live_...
```

### 3. Gate a function

```python
from pliuz import gated

@gated(policy="risky_query")
def run_sql(query: str) -> list[dict]:
    return db.fetch(query)

# This call blocks until a human approves (or rejects, or it expires).
rows = run_sql("DELETE FROM users WHERE created_at < '2024-01-01'")
```

That's it. Pliuz handles the rest: idempotency, retries, audit logging, hash-chained event log.

## What `@gated` does

```
your_fn(*args)                                    your_fn(*args)
     │                                                  │
     ▼                                                  ▼
 ┌──────────┐  POST /approvals                    ┌──────────┐
 │  pliuz   │ ───────────────►  ┌─────────────┐  │  pliuz   │
 │  gated   │                   │   Pliuz API  │  │  gated   │
 │          │  ◄─────────────── └─────────────┘  │          │
 └──────────┘     status=pending          ▲      └──────────┘
     │                                    │           │
     │              poll every 2s         │           ▼
     │ ────────────────────────────────► humans  ┌──────────┐
     ▼                                    │      │ original │
 ┌──────────┐  status=approved            │      │   fn()   │
 │ execute  │ ◄──────────────────────────┘      └──────────┘
 │ original │                                         │
 │   fn()   │  POST /:id/execution                    ▼
 └──────────┘ ─────────────────────────►       audit log
     │
     ▼
  result
```

## Features

- **Sync + async** auto-detection via `inspect.iscoroutinefunction`
- **Idempotency** via deterministic key hashing — safe to retry from anywhere
- **Client-side redaction** — sensitive fields never leave your process plaintext
- **Polling with backoff** — uses `time.sleep` for sync, `asyncio.sleep` for async
- **Single-shot execution reporting** — closes the audit loop automatically
- **Typed errors** — `PliuzRejectedError`, `PliuzApprovalExpiredError`, `PliuzApprovalTimeoutError`, `PliuzPolicyError`, etc.
- **Framework adapters** — `@gated_tool` stacks on the framework's tool decorator so existing agents gate transparently. Dedicated adapters for **LangChain**, the **Claude Agent SDK**, and the **OpenAI Agents SDK** (any other framework still works via `@gated` on your own functions)

---

## API

### `@gated` — the headline

```python
@gated(
    policy="...",                    # Pliuz policy slug to bind (or None to match by tool_name)
    redact=["customer.ssn"],         # Dotted paths to strip BEFORE sending
    timeout_s=300,                   # Max polling duration (5 min default)
    poll_interval_s=2,               # Time between status checks
    tool_name="custom_name",         # Override fn.__name__
    client=PliuzClient(api_key="..."),  # Reuse a client (default: lazy from env)
    context_messages=["context for the human"],
    session_id="trace-abc",
    originator=Originator(type="user", id="user-42"),
)
def my_function(...): ...
```

Both forms work:

```python
@gated  # uses defaults from env
def f(...): ...

@gated(policy="...")  # with options
def f(...): ...

@gated(...)
async def f(...): ...  # auto-detected
```

### Errors

```python
from pliuz import (
    PliuzError,                     # base
    PliuzApiError,                  # any 4xx/5xx from Pliuz API
        PliuzAuthError,             # 401 — bad/missing key
        PliuzForbiddenError,        # 403 — agent mismatch
        PliuzNotFoundError,         # 404 — approval not found
        PliuzConflictError,         # 409 — duplicate execution report
        PliuzValidationError,       # 400 — invalid body
        PliuzPolicyError,           # 422 — no policy matched
        PliuzRateLimitError,        # 429
        PliuzServerError,           # 5xx
    PliuzNetworkError,              # connection failed
    PliuzTimeoutError,              # request timed out
    PliuzRejectedError,             # human said no
    PliuzApprovalExpiredError,      # SLA expired before human decided
    PliuzApprovalTimeoutError,      # SDK polling gave up
)
```

### Low-level client

```python
from pliuz import PliuzClient, AsyncPliuzClient

with PliuzClient() as pliuz:
    resp = pliuz.create_approval(
        tool_name="refund",
        tool_args={"amount": 100},
        idempotency_key="abc-123",
    )

    full = pliuz.get_approval(resp.id)

    pliuz.report_execution(
        resp.id,
        status="success",
        latency_ms=42,
    )

# Async — same surface, every method awaitable
async with AsyncPliuzClient() as pliuz:
    resp = await pliuz.create_approval(...)
```

### Redaction

```python
from pliuz import apply_redaction

clean = apply_redaction(
    {"customer": {"ssn": "123-45-6789", "id": "cus_x"}},
    ["customer.ssn"],
)
# {"customer": {"ssn": "<redacted>", "id": "cus_x"}}
```

Supports dotted paths and `[*]` for array wildcards:

```python
apply_redaction({"items": [{"card": "..."}]}, ["items[*].card"])
```

### LangChain integration (optional)

```bash
pip install 'pliuz[langchain]'
```

```python
from langchain_core.tools import tool
from pliuz.adapters.langchain import gated_tool

@gated_tool(policy="refund", redact=["customer.ssn"])
@tool
def issue_refund(customer_id: str, amount_cents: int) -> str:
    """Issue a refund to a customer."""
    return stripe.refunds.create(customer=customer_id, amount=amount_cents).id

# Pass to an agent like any other tool — the LLM sees it identically.
agent = create_react_agent(llm, tools=[issue_refund])
```

Or wrap existing tools compositionally:

```python
from pliuz.adapters.langchain import wrap_tool

agent.tools = [
    wrap_tool(my_tool, policy="my_policy", redact=["secret"]),
    # ...
]
```

### Claude Agent SDK integration (optional)

```bash
pip install 'pliuz[claude-agent]'
```

`@gated_tool` stacks on the SDK's `@tool`. The model sees the tool identically
(same name, description, `input_schema`); only the execution path is gated.

```python
from claude_agent_sdk import tool, create_sdk_mcp_server
from pliuz.adapters.claude_agent import gated_tool

@gated_tool(policy="refund", redact=["customer_id"])
@tool("issue_refund", "Issue a refund", {"customer_id": str, "amount_cents": int})
async def issue_refund(args: dict) -> dict:
    refund_id = stripe.refunds.create(customer=args["customer_id"], amount=args["amount_cents"]).id
    return {"content": [{"type": "text", "text": refund_id}]}

server = create_sdk_mcp_server(name="billing", version="1.0.0", tools=[issue_refund])
```

Or wrap an existing `SdkMcpTool` with `wrap_tool(my_tool, policy="...", redact=[...])`.

### OpenAI Agents SDK integration (optional)

```bash
pip install 'pliuz[openai-agents]'
```

`@gated_tool` stacks on `@function_tool`, preserving `name`, `description`, and
`params_json_schema`. The args arrive as a JSON string with a live `ToolContext`
alongside; the adapter parses them to a flat dict so top-level `redact` paths
match and the `ToolContext` never leaks into the audit payload or the
idempotency key.

```python
from agents import Agent, function_tool
from pliuz.adapters.openai_agents import gated_tool

@gated_tool(policy="refund", redact=["customer_id"])
@function_tool
def issue_refund(customer_id: str, amount_cents: int) -> str:
    """Issue a refund to a customer."""
    return stripe.refunds.create(customer=customer_id, amount=amount_cents).id

agent = Agent(name="Billing", instructions="...", tools=[issue_refund])
```

Or wrap an existing `FunctionTool` with `wrap_tool(my_tool, policy="...", redact=[...])`.

> The OpenAI Agents SDK also ships a built-in `needs_approval` flag, but it only
> pauses the local run loop — it has no external routing, audit trail, or
> approver UI. Use this adapter when approval must leave the process and be
> recorded in Pliuz's hash-chained audit log.

---

## Configuration

| Env var | Default | Purpose |
|---|---|---|
| `PLIUZ_API_KEY` | _(required)_ | Per-agent API key. `pli_live_...` format. |
| `PLIUZ_BASE_URL` | `https://pliuz.com` | Override for self-hosted or staging environments. |

All env vars can be passed as constructor kwargs:

```python
PliuzClient(api_key="pli_live_...", base_url="https://pliuz.mycompany.com")
```

---

## Production tips

### Idempotency — read this before relying on it

`@gated` automatically generates a deterministic `idempotency_key` per call from `(tool_name, args, session_id)`. The key is hashed over the **pre-redaction** args (only the truncated SHA-256 leaves your process), so two calls that differ only in a redacted field get **different** keys — each needs its own approval.

**Important:** the backend dedupes on this key within a **24-hour window**, scoped to the tenant and originating agent. An identical `(tool_name, args, session_id)` combination replays the same approval during that window, which makes HTTP retries safe. Reusing the same `idempotency_key` within 24 hours with different `tool_args` is rejected as an idempotency conflict. After the window expires, the same key can create a new approval.

With the default `session_id=None`, repeated identical calls from the same agent can still replay for up to 24 hours. **If your agent legitimately repeats identical calls (crons, recurring jobs), set a per-run `session_id`** so each run gets its own approval:

```python
@gated(policy="refund", session_id=run_id)  # scope approvals to this run
def refund(customer_id, cents):
    ...
```

Within one run, retries are then safe: same args → one approval request, one human decision.

### When the approver edits the args

If a human decides with **Edit & Approve**, `@gated` executes the **edited** `final_args` (bound back onto your function's signature), never the original call. If the edited args don't bind to the signature, the SDK raises `PliuzEditNotApplicableError` — it refuses to run args the human did not approve. Fields the approver left as `<redacted>` are restored to the original values before execution.

### Decision latency (long-poll)

`@gated` waits for the human decision via **server long-poll**: each wait call
holds the connection open up to ~25s and returns the instant the approval is
decided. Near-zero decision-delivery latency and ~12x fewer requests than
fixed-interval polling, with no configuration. `poll_interval_s` is only the
safety-floor backoff used if the server ignores the `wait` hint (it does NOT
shorten the long-poll window). The low-level client exposes it directly:

```python
approval = pliuz.get_approval(approval_id, wait_seconds=25)  # long-poll
```

### Custom timeouts per call

Don't set globally. Pick per use case:

- **Live user-facing** (chat agent waiting for a refund approval): `timeout_s=30`
- **Background jobs** (overnight batch): `timeout_s=86400` (24 h)
- **Critical security gates** (deploy approvals): no `@gated` — use a synchronous Pliuz dashboard flow

### When `@gated` raises mid-execution

If the human takes a long time and your polling times out, `@gated` raises `PliuzApprovalTimeoutError`. **Your gated function never runs.** The approval may still resolve later server-side — handle the retry policy yourself (`PliuzClient.get_approval(id)`).

### What's NOT retried automatically

- `POST /approvals` **without** `idempotency_key` (would create duplicates)
- `POST /approvals/:id/execution` (single-shot — duplicate report = 409)

Everything else: GETs, 408, 429, 5xx → exponential backoff with full jitter, 3 retries default.

---

## Versioning

This SDK follows [SemVer](https://semver.org). The current line is `0.x.y` — minor versions may include breaking changes until `1.0.0`.

| Surface | Stability |
|---|---|
| `gated`, `PliuzClient`, `AsyncPliuzClient` | Stable across 0.x patches |
| `pliuz.errors.*` | Stable across 0.x patches |
| `pliuz.adapters.*` | Experimental — may shift before 1.0 |
| Internal `pliuz._*` | Unstable — don't import |

---

## Examples

See [`examples/`](./examples) for runnable scripts:

- [`basic.py`](./examples/basic.py) — bare `PliuzClient` (no decorator)
- [`gated_basic.py`](./examples/gated_basic.py) — `@gated` happy path
- [`gated_async.py`](./examples/gated_async.py) — async function with `@gated`
- [`langchain_agent.py`](./examples/langchain_agent.py) — full LangChain agent with gated tools
- [`claude_agent.py`](./examples/claude_agent.py) — Claude Agent SDK tool gated with `@gated_tool`
- [`openai_agents.py`](./examples/openai_agents.py) — OpenAI Agents SDK tool gated with `@gated_tool`

---

## Links

- **Docs**: https://pliuz.com/docs
- **GitHub**: https://github.com/pliuz/pliuz-py
- **Issues**: https://github.com/pliuz/pliuz-py/issues
- **TypeScript SDK**: [`@pliuz/sdk`](https://www.npmjs.com/package/@pliuz/sdk)

---

## License

[Apache 2.0](./LICENSE). The Pliuz platform itself is proprietary — this SDK is the public client only.
