Metadata-Version: 2.4
Name: felspar
Version: 0.1.0a1
Summary: Typed Felspar client and synchronous action gate for agents (alpha)
Project-URL: Homepage, https://felspar.ai
Project-URL: Documentation, https://docs.felspar.ai
Author: Felspar
License-Expression: Apache-2.0
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.11
Requires-Dist: attrs>=23
Requires-Dist: httpx<1,>=0.27
Requires-Dist: python-dateutil<3,>=2.8
Description-Content-Type: text/markdown

# Felspar for Python (alpha)

Felspar gives agents a shared, versioned record of what your company has approved.
This package is the Python client and a synchronous action gate: before an agent
acts, the gate checks the action against a verified Felspar decision and denies
anything it cannot verify.

This is an alpha. The API may change between releases, and it does not yet match
a stable `@felspar/sdk` 1.0 contract; it implements the tested 0.4.1 gate
behavior. Pin the exact version you test.

## Install

```sh
pip install felspar --pre
```

Python 3.11+ is required. The typed client for all six JSON operations is included
under `felspar.generated`.

## Quickstart

You need an agent key and a job in a released Pack. Set `FELSPAR_ORIGIN` to
`https://api.felspar.ai`, and keep the key in the environment, never in code.

```python
import os
from felspar import Client, ActionGate, denial_text
from felspar.generated.api.agents import decide_job
from felspar.generated.models.decision_input_any_of_variant_0 import DecisionInputAnyOfVariant0

# request_json is the job's actual documented request, including source evidence.
# session_id and tool rules are controlled by your application, never the model.
with Client(os.environ["FELSPAR_ORIGIN"], os.environ["FELSPAR_AGENT_KEY"]) as client:
    gate = ActionGate(
        rules={"job": "review-request", "tools": {"reply": {
            "writes": "request-reply", "fields": ["caseId", "text"],
            "entity": {"field": "caseId"}, "outputCheck": {"fields": ["text"]},
        }}},
        verify=client.verify, describe=client.describe,
        verify_output_check=client.verify_output_check,
    )
    body = DecisionInputAnyOfVariant0.from_dict(request_json)
    response = decide_job.sync_detailed("review-request", client=client.transport, body=body)
    if response.status_code != 200:
        raise RuntimeError("Decision request failed")
    gate.decisions.record(session_id, {"job": "review-request", "entity": body.entity_id}, response.parsed)
    # Generate text, call generated check_output with OutputCheckInput, then:
    # gate.decisions.record_output_check(session_id, "review-request", checked.parsed)
    verdict = gate.check(session_id, "reply", {"caseId": case_id, "text": text})
    if not verdict["allow"]:
        raise RuntimeError(denial_text(verdict))
    reply(case_id, text)
```

The snippet's request, case, text and reply function belong to your application;
job/output IDs must match its released Pack.

## The gate

`ActionGate.check(session_id, tool_name, input)` returns a JSON verdict with
`allow`, `reason`, and either the verified `decision` or optional `escalate` and
`escalation`. `denial_text` formats the same denial as TypeScript. Rules retain
wire-compatible camelCase keys so shared configuration and fixtures are exact.
Use exactly one of `outcomes` or `writes`, and an explicit top-level field allow
list. `refuse` takes precedence. Tool names match exactly.

Case binding supports provided/exact and ASCII case-insensitive IDs, issued,
fields and content identities, explicit entity fields, or one implicit case.
Legacy company-domain jobs bind all recipients; shared providers/public suffixes
are excluded by the same compact list as TypeScript (`sharedDomains` extends it).
Test recipients must all be listed. The newest recorded decision for that case
wins. Replay must match job, case ID/mode and any `releases` pin. Evidence expires
at the server-reported boundary; no freshness limit means no additional local
expiry, as in TypeScript. Missing evidence produces no usable decision on the
server. `writes` reads outputs from the release; output checks require exact
UTF-8 text, decision digest, output identity and a fresh server readback.

Record only direct Felspar responses, never a model's reconstruction. The recorder
copies data and bounds sessions (1000) and records per session (200), and supports
`forget(session_id)`. It is process-local; serialize access to a session and do
not share it across tenants. Hooks must use host-owned session IDs. Verification
is an action-time read, not a one-use authorization or a guarantee of downstream
execution. Customers own tool execution, credentials and idempotency.

The generated brief route follows the agent grant, so a changed grant denies an
older decision rather than substituting another release's description. The Python
facade does not yet provide the TypeScript retained-release description route.
JSON responses are supported; MCP/content-part unwrapping and split request/result
recording are intentionally outside this JSON-only API. Network and malformed
transport errors are sanitized and deny; no redirects or ambient HTTP proxies
are used. Production requires HTTPS; loopback HTTP supports local testing.

## OpenAI Agents hook

This hook uses the SDK's function-tool input guardrail API, tested with
`openai-agents==0.24.0` ([upstream contract](https://openai.github.io/openai-agents-python/guardrails/#tool-guardrails)).
Attach it to each action tool. The gate's tool rule must use the function's exact
name, and the host's Runner context must contain `session_id`; never take it from
tool input.

```python
import asyncio
import json
from agents import Agent, Runner, ToolGuardrailFunctionOutput, function_tool, tool_input_guardrail
from felspar import denial_text


def guardrail(gate):
    @tool_input_guardrail
    async def verify_before_action(data):
        try:
            context = data.context
            arguments = json.loads(context.tool_arguments)
            session = context.context["session_id"]
            if not isinstance(session, str) or not session:
                raise ValueError("missing session")
            verdict = await asyncio.to_thread(gate.check, session, context.tool_name, arguments)
            if verdict["allow"] is True:
                return ToolGuardrailFunctionOutput.allow()
            if verdict["allow"] is not False:
                raise ValueError("invalid verdict")
            return ToolGuardrailFunctionOutput.reject_content(denial_text(verdict))
        except Exception:
            return ToolGuardrailFunctionOutput.reject_content(
                "Action denied: unreadable action, verdict or missing host session."
            )

    return verify_before_action


@function_tool(tool_input_guardrails=[guardrail(gate)])
def reply(caseId: str, text: str) -> str:
    """Write the verified reply for this case."""
    return write_reply(caseId, text)


agent = Agent(name="request assistant", tools=[reply])
# Supply a host-owned session that already contains the direct decision/check.
result = await Runner.run(agent, prompt, context={"session_id": session_id})
```

This covers function tools attached to this guardrail, not hosted tools or other
unguarded execution paths. A denied call returns the denial to the model without
running the function.

## Documentation

- [Felspar docs](https://docs.felspar.ai)
- [OpenAI Agents SDK integration](https://docs.felspar.ai/integrations/openai-agents-sdk)
