Metadata-Version: 2.4
Name: stryda-langchain
Version: 0.1.0
Summary: Drop Stryda governance into a LangChain agent — wrap a BaseTool, or attach a callback to an AgentExecutor.
Project-URL: Homepage, https://stryda.ai
Project-URL: Documentation, https://docs.stryda.ai
Project-URL: Repository, https://github.com/Srujyama/Stryda
Author: Stryda
License: MIT
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Requires-Dist: langchain-core<2,>=0.3
Requires-Dist: stryda-sdk>=0.1
Provides-Extra: test
Requires-Dist: httpx>=0.26; extra == 'test'
Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
Requires-Dist: pytest>=7; extra == 'test'
Description-Content-Type: text/markdown

# stryda-langchain

Drop Stryda governance into a LangChain agent with ~3 lines of wiring.

This package is the LangChain-native surface on top of [`stryda-sdk`](../stryda-sdk-python/README.md). It ships two adapters — a tool wrapper and a callback handler — so you can pick whichever matches your existing agent structure. Both paths funnel every tool call through Stryda's `stryda.check_action` → execute → `stryda.record_outcome` pipeline.

## Install

Not published to PyPI yet. Install from this monorepo:

```bash
pip install -e ./packages/stryda-sdk-python
pip install -e ./packages/stryda-langchain
```

Requires Python 3.10+ and `langchain-core >= 0.3`.

## Option A — `StrydaToolWrapper` (recommended)

Wrap any `BaseTool`. The LLM sees the same tool (same `name`, `description`, `args_schema`), but every invocation goes through Stryda first. On `deny`/`escalate` the agent sees a readable policy reason as the tool output and can course-correct.

```python
from langchain.agents import create_openai_tools_agent, AgentExecutor
from langchain_core.tools import tool
from stryda_sdk import StrydaClient
from stryda_langchain import StrydaToolWrapper

stryda = StrydaClient(api_key=os.environ["STRYDA_API_KEY"])

@tool
def refund_charge(charge_id: str, amount_cents: int) -> dict:
    """Refund a Stripe charge."""
    return stripe.Refund.create(charge=charge_id, amount=amount_cents)

# One-liner — the rest of your agent setup is unchanged.
governed_refund = StrydaToolWrapper(
    tool=refund_charge,
    action_type="payments.refund",
    stryda_client=stryda,
)

executor = AgentExecutor(
    agent=agent,
    tools=[governed_refund, other_tools...],
)
```

**What happens on each invocation:**

| Stryda decision | Wrapped tool runs? | Agent sees                                     |
|-----------------|--------------------|------------------------------------------------|
| `allow`         | Yes                | Real tool result; `record_outcome=success`     |
| `deny`          | No                 | `"[stryda:denied] refund_charge was not run. Reason: …"` |
| `escalate`      | No                 | `"[stryda:pending_approval] … (escalation_id=esc_xyz)"` |
| Stryda 5xx      | No                 | `StrydaError` raised — **fail closed by design** |
| Tool raises     | —                  | `record_outcome=error`, exception re-raised    |

## Option B — `StrydaCallback`

If you can't (or don't want to) rewrite your tool objects, attach the callback at the `AgentExecutor` level. It governs every tool call in the agent loop.

```python
from stryda_langchain import StrydaCallback

executor = AgentExecutor(
    agent=agent,
    tools=tools,                        # unchanged — still BYO-key
    callbacks=[StrydaCallback(
        stryda_client=stryda,
        action_type_map={
            "refund_charge":  "payments.refund",
            "send_email":     "comms.email_send",
            "create_ticket":  "crm.ticket_create",
        },
    )],
)
```

The callback has `raise_error = True`, so a `DeniedError` raised from `on_tool_start` propagates through LangChain's callback manager and the tool is **never executed**. Unmapped tool names fall back to the tool's lowercased name coerced into Stryda's `action_type` regex.

> **Why only one class?** Earlier versions of this package shipped a separate `AsyncStrydaCallback` subclassing `AsyncCallbackHandler`. It had a silent correctness bug — when an agent used `ainvoke` on a sync tool, LangChain's dispatcher sometimes routed async handlers through `_run_coros`, which swallows exceptions unconditionally. A `denied` decision would then log-and-allow. The single sync-methods class avoids that path entirely. The import `AsyncStrydaCallback` is still exported as an alias of `StrydaCallback` for back-compat.

## Offline attestation verification

Every authorized tool call produces a signed Ed25519 JWT. To verify stored attestations without hitting Stryda, use `stryda-sdk`'s verifier:

```python
from stryda_sdk import fetch_jwks, verify_attestation

jwks = fetch_jwks("https://api.stryda.ai")   # cache per-process
claims = verify_attestation(jwt_token, jwks, audience="mcp-governance")
```

## BYO-key stays BYO-key

Stryda never holds your Stripe / Gmail / Salesforce credentials. `StrydaToolWrapper` calls your original tool's `_run` with the original args; `StrydaCallback` lets LangChain execute your tool as it normally would. Stryda only authorizes and records. If your Stryda endpoint is unreachable, the wrapper fails closed (`StrydaError`) — it will never silently bypass governance.

## Troubleshooting

**Callback raised `DeniedError` but LangChain warned and ran the tool anyway.**
You probably attached a handler that subclasses `AsyncCallbackHandler` with `async def` methods on a sync tool. Use `StrydaCallback` — it works in both sync and async paths.

**Tool ran but no attestation in Stryda's ledger.**
Check the `check_id` in your logs. `record_outcome` is best-effort (the action already happened); transient Stryda 5xx errors on record do NOT roll back the tool call. Your ledger-verify endpoint (`GET /api/ledger/verify`) will surface the gap if it drifted.

**Wrapper is blocking LLM tool calling.**
Make sure you passed `tool=` (not `tool_cls=`) a **concrete instance** of `BaseTool`, not a class. LangChain introspects `args_schema` on the instance.

## Related

- [`stryda-sdk-python`](../stryda-sdk-python/README.md) — the underlying HTTP client + `governed` context manager
- [`stryda-langgraph`](../stryda-langgraph/README.md) — policy node for LangGraph state machines
- Mission + architecture: `MISSION.md`, `docs/system-architecture.md`
