Metadata-Version: 2.4
Name: strahl
Version: 0.1.1
Summary: Strahl Prism Python SDK.
Author: Strahl Labs
License-Expression: Apache-2.0
Project-URL: Homepage, https://github.com/strahl-labs/strahl-client
Project-URL: Repository, https://github.com/strahl-labs/strahl-client
Keywords: llm,information-flow-control,agents,security,ifc
Classifier: Programming Language :: Python :: 3
Classifier: Operating System :: OS Independent
Classifier: Intended Audience :: Developers
Classifier: Topic :: Security
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.28.1
Requires-Dist: pydantic>=2.13.4
Provides-Extra: straylight
Requires-Dist: straylight-agent>=0.1.0; python_version >= "3.14" and extra == "straylight"
Provides-Extra: all
Requires-Dist: straylight-agent>=0.1.0; python_version >= "3.14" and extra == "all"
Provides-Extra: docs
Requires-Dist: mkdocs-material>=9.0; extra == "docs"
Requires-Dist: mkdocstrings[python]>=0.26; extra == "docs"
Requires-Dist: mkdocs-gen-files>=0.5; extra == "docs"
Requires-Dist: mkdocs-literate-nav>=0.6; extra == "docs"
Requires-Dist: mkdocs-section-index>=0.3; extra == "docs"
Requires-Dist: ruff>=0.15.17; extra == "docs"
Dynamic: license-file

# Strahl Prism Python SDK

Prism analyzes tool-call transcripts before tools execute. You label message
roles and registered tools, then call `analyze(messages)` on a transcript ending
in the assistant tool-call response.

The SDK is pre-release and intentionally small. It supports Python 3.11+ and
uses `httpx` and `pydantic`.

## Install

```bash
pip install strahl
```

With `uv`:

```bash
uv add strahl
```

## Quick Start

Set `STRAHL_API_KEY` in your environment, then use the module-level helpers for
simple applications:

```python
import strahl
from strahl import Label

strahl.set_role_labels({
    "user": Label(source={"user"}, visibility={"user"}),
    "assistant": Label(source={"assistant"}, visibility={"user"}),
})


@strahl.tool(
    requires=Label(source={"user"}, visibility={"user"}),
    produces=Label(source={"email-tool"}, visibility={"user"}),
)
def send_email(to: str, subject: str, body: str) -> str:
    ...


messages = [
    {"role": "user", "content": "Send a note to alice@example.com."},
    {
        "role": "assistant",
        "content": None,
        "tool_calls": [
            {
                "id": "call_1",
                "function": {
                    "name": "send_email",
                    "arguments": (
                        '{"to": "alice@example.com", '
                        '"subject": "Note", '
                        '"body": "Hello"}'
                    ),
                },
            }
        ],
    },
]

analysis = strahl.analyze(messages)
analysis.raise_if_denied()
```

`raise_if_denied()` raises `StrahlDenied` if any final tool call is denied.
HTTP errors remain `httpx.HTTPStatusError`.

## Labels

Labels have two tag sets:

- `source`: where the information came from, used for integrity checks.
- `visibility`: where the information may flow, used for confidentiality checks.

```python
Label(source={"user"}, visibility={"user", "support"})
```

Labels may be dynamic when attached to tools:

```python
Label(source={"support-agent"}, visibility=lambda customer_id: {f"customer:{customer_id}"})
```

Callable label parameters must match declared tool parameters. Role labels must
be static.

## Tools

Register Python callables with a decorator:

```python
@strahl.tool(
    requires=Label(source={"support-agent"}, visibility={"support-agent"}),
    produces=Label(source={"crm"}, visibility=lambda customer_id: {f"customer:{customer_id}"}),
)
def lookup_customer(customer_id: str) -> str:
    ...
```

Or imperatively:

```python
strahl.add_tool(
    fn=lookup_customer,
    requires=Label(source={"support-agent"}, visibility={"support-agent"}),
    produces=Label(source={"crm"}, visibility=lambda customer_id: {f"customer:{customer_id}"}),
)
```

Use `params` when individual arguments have different requirements:

```python
@strahl.tool(
    requires=Label(source={"assistant"}, visibility={"user"}),
    params={
        "to": Label(source={"user"}, visibility={"user"}),
        "subject": Label(source={"user"}, visibility={"user"}),
        "body": Label(source={"user"}, visibility={"user"}),
    },
    produces=Label(source={"email-tool"}, visibility={"user"}),
)
def send_email(to: str, subject: str, body: str) -> str:
    ...
```

If `params` is omitted, every actual tool-call argument inherits `requires`. If
`params` is provided, every declared top-level parameter must be listed.

You can also register OpenAI function tool schemas:

```python
openai_tool = {
    "type": "function",
    "function": {
        "name": "lookup_customer",
        "description": "Look up a customer record.",
        "parameters": {
            "type": "object",
            "properties": {"customer_id": {"type": "string"}},
            "required": ["customer_id"],
        },
    },
}

strahl.add_tool(
    fn=openai_tool,
    requires=Label(source={"support-agent"}, visibility={"support-agent"}),
    produces=Label(source={"crm"}, visibility=lambda customer_id: {f"customer:{customer_id}"}),
)
```

## Isolated Runtimes

The top-level helpers use a process-global default `Prism` runtime. Use
`Prism` directly when you need isolated state for different agents, tenants, or
test cases:

```python
import os
from strahl import Label, Prism

prism = Prism(api_key=os.environ["STRAHL_API_KEY"])
prism.set_role_labels({
    "user": Label(source={"user:alice"}, visibility={"user:alice"}),
    "assistant": Label(source={"assistant"}, visibility={"user:alice"}),
    "system": Label(source={"system"}, visibility={"internal"}),
})

prism.add_tool(
    name="send_email",
    fn=send_email,
    requires=Label(source={"user:alice"}, visibility={"user:alice"}),
    produces=Label(source={"email-tool"}, visibility={"user:alice"}),
)

analysis = prism.analyze(messages)
```

Use `Prism.from_default()` when modules register tools through top-level
decorators and you want an isolated runtime seeded with those registrations:

```python
import strahl
from strahl import Label, Prism

import my_tools

prism = Prism.from_default()
prism.set_role_labels({
    "user": Label(source={"user:alice"}, visibility={"user:alice"}),
    "assistant": Label(source={"assistant"}, visibility={"user:alice"}),
})
```

## OpenAI Message Format

Call `analyze()` after the assistant response that requests tool calls, before
executing those tools:

```python
messages = [
    {"role": "user", "content": "Find my order."},
    {
        "role": "assistant",
        "content": None,
        "tool_calls": [
            {
                "id": "call_lookup",
                "function": {
                    "name": "lookup_order",
                    "arguments": '{"order_id": "ord_123"}',
                },
            }
        ],
    },
]
```

Tool result messages from earlier turns may be present in the transcript:

```python
{"role": "tool", "tool_call_id": "call_lookup", "content": "Order found: ..."}
```

The final trace item must be a pending tool call.

## Low-Level Client

`PrismClient` exposes the raw Prism API when you already have wire models:

```python
from strahl.client import PrismClient

client = PrismClient()
response = client.analyses.create(request)
```

Most applications should use `Prism` or the module-level helpers instead.

## Reading Results

```python
analysis = strahl.analyze(messages)

for tool_call in analysis:
    print(tool_call.name, "denied" if tool_call.denied else "permitted")
    for violation in tool_call.violations:
        print(violation.explain())
        print(violation.evidence_spans)
        print(violation.evidence)

analysis.raise_if_denied()
```

`AnalysisResult` exposes:

- `id`
- `created_at`
- `prompt_tokens`
- `tool_calls`
- `denied`
- `denied_tool_calls`
- `explain()`
- `raise_if_denied()`

Each high-level `Violation` exposes the server-provided `evidence_spans` as the
canonical evidence representation. Its `evidence` property is display-only: it
slices the matching source text and joins disjoint spans with `...`.
Confidentiality and integrity explanations format the reason tags returned by
the server; the SDK does not re-evaluate policy labels.

For now, the SDK trims surrounding source whitespace to mirror the server's
canonical trace normalization. Therefore the spans do not yet address the
byte-for-byte request text. Original-trace coordinate mapping is tracked in
[strahl-labs/strahl#51](https://github.com/strahl-labs/strahl/issues/51).

## Development

```bash
uv run pytest -q tests/test_result.py tests/test_client.py tests/test_tools.py
uv run python -m compileall -q strahl
```
