Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.1] - 2026-03-26

### Added

- **OpenAI Agents SDK Adapter** — `ContractRunHooks(RunHooks)` for effect gating via `on_tool_start`, token tracking via `on_llm_end`, postcondition evaluation via `on_agent_end`. Pinned to `openai-agents==0.8.4`
- **Claude Agent SDK Adapter** — `ContractHooks` with structured deny via PreToolUse (not exception). Cost/token extraction from ResultMessage. Pinned to `claude-agent-sdk==0.1.50` (Python 3.10+)
- **Precondition Evaluation** — `contract.preconditions[]` evaluated on input BEFORE agent runs. Reuses CEL-like expression evaluator. `PreconditionError` blocks execution before tokens are spent. Wired into `ContractEnforcer.check_preconditions()` and `@enforce_contract` decorator
- **GitHub Action** — `pyyush/agentcontracts@v0.1.1` composite action for CI contract validation
- **README Badge** — PyPI version and CI status badges
- 35 new tests (188 total)

## [0.1.0] - 2026-03-25

First release. YAML spec + Python SDK for production agent reliability.
Expand Down Expand Up @@ -32,4 +43,5 @@ First release. YAML spec + Python SDK for production agent reliability.
- **Specification** — Human-readable spec narrative (`SPECIFICATION.md`)
- **Examples** — Reference contracts for all 3 tiers

[0.1.1]: https://github.com/pyyush/agentcontracts/releases/tag/v0.1.1
[0.1.0]: https://github.com/pyyush/agentcontracts/releases/tag/v0.1.0
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ src/agent_contracts/
langchain.py LangChain CallbackHandler
crewai.py CrewAI ContractGuard
pydantic_ai.py Pydantic AI ContractMiddleware
openai_agents.py OpenAI Agents SDK RunHooks
claude_agent.py Claude Agent SDK ContractHooks
examples/ Reference contracts (Tier 0, 1, 2)
tests/ pytest test suite
```
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Agent Contracts

[![PyPI](https://img.shields.io/pypi/v/aicontracts)](https://pypi.org/project/aicontracts/)
[![CI](https://github.com/pyyush/agentcontracts/actions/workflows/ci.yml/badge.svg)](https://github.com/pyyush/agentcontracts/actions/workflows/ci.yml)

**YAML spec + validation SDK for production agent reliability.**

Cost control, tool-use security, and audit trails in under 30 minutes of integration. Works with any framework. Enforces at the runtime layer, not via prompts.
Expand Down Expand Up @@ -98,6 +101,20 @@ middleware = ContractMiddleware.from_file("AGENT_CONTRACT.yaml")
result = await middleware.run(agent, prompt)
```

**OpenAI Agents SDK:**
```python
from agent_contracts.adapters.openai_agents import ContractRunHooks
hooks = ContractRunHooks.from_file("AGENT_CONTRACT.yaml")
result = await Runner.run(agent, "prompt", run_hooks=[hooks])
```

**Claude Agent SDK:**
```python
from agent_contracts.adapters.claude_agent import ContractHooks
hooks = ContractHooks.from_file("AGENT_CONTRACT.yaml")
# Pass hooks.pre_tool_use to ClaudeAgentOptions
```

## Three Tiers

Start simple, add guarantees as production demands.
Expand Down
58 changes: 58 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: 'AI Contracts Validate'
description: 'Validate agent contracts against the AI Contracts spec'
branding:
icon: 'shield'
color: 'blue'

inputs:
contract:
description: 'Path to contract YAML file(s), space-separated'
required: true
fail-on-warning:
description: 'Fail if contract has upgrade recommendations'
required: false
default: 'false'
python-version:
description: 'Python version to use'
required: false
default: '3.11'

outputs:
outcome:
description: 'pass or fail'
value: ${{ steps.validate.outputs.outcome }}
tier:
description: 'Contract tier (0, 1, or 2)'
value: ${{ steps.validate.outputs.tier }}

runs:
using: 'composite'
steps:
- uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}

- name: Install aicontracts
shell: bash
run: pip install aicontracts

- name: Validate contracts
id: validate
shell: bash
run: |
outcome="pass"
for contract in ${{ inputs.contract }}; do
echo "::group::Validating $contract"
result=$(aicontracts validate "$contract" -j)
if [ $? -eq 0 ]; then
tier=$(echo "$result" | python3 -c "import sys,json; print(json.load(sys.stdin)['tier'])")
echo "tier=$tier" >> "$GITHUB_OUTPUT"
else
outcome="fail"
fi
echo "::endgroup::"
done
echo "outcome=$outcome" >> "$GITHUB_OUTPUT"
if [ "$outcome" = "fail" ]; then
exit 1
fi
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,15 @@ otel = ["opentelemetry-api>=1.20"]
langchain = ["langchain-core>=0.2"]
crewai = ["crewai>=0.50"]
pydantic-ai = ["pydantic-ai>=0.1"]
openai = ["openai-agents==0.8.4"]
claude = ["claude-agent-sdk==0.1.50; python_version>='3.10'"]
all = [
"aicontracts[otel]",
"aicontracts[langchain]",
"aicontracts[crewai]",
"aicontracts[pydantic-ai]",
"aicontracts[openai]",
"aicontracts[claude]",
]
dev = [
"pytest>=8.0",
Expand Down
5 changes: 4 additions & 1 deletion src/agent_contracts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from agent_contracts.effects import EffectDeniedError, EffectGuard
from agent_contracts.enforcer import ContractEnforcer, ContractViolation, enforce_contract
from agent_contracts.loader import ContractLoadError, load_contract, validate_contract
from agent_contracts.postconditions import PostconditionError
from agent_contracts.postconditions import PostconditionError, PreconditionError
from agent_contracts.tier import TierRecommendation, assess_tier, recommend_upgrades
from agent_contracts.types import (
Contract,
Expand All @@ -27,6 +27,7 @@
FailureModel,
ObservabilityConfig,
PostconditionDef,
PreconditionDef,
ResourceBudgets,
SLOConfig,
VersioningConfig,
Expand All @@ -39,6 +40,7 @@
"Contract",
"ContractIdentity",
"PostconditionDef",
"PreconditionDef",
"EffectsAuthorized",
"EffectsDeclared",
"ResourceBudgets",
Expand Down Expand Up @@ -67,6 +69,7 @@
"BudgetExceededError",
# Postconditions
"PostconditionError",
"PreconditionError",
# Violations
"ViolationEvent",
"ViolationEmitter",
Expand Down
2 changes: 1 addition & 1 deletion src/agent_contracts/_version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Agent Contracts version."""

__version__ = "0.1.0"
__version__ = "0.1.1"
2 changes: 2 additions & 0 deletions src/agent_contracts/adapters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@
pip install aicontracts[langchain]
pip install aicontracts[crewai]
pip install aicontracts[pydantic-ai]
pip install aicontracts[openai]
pip install aicontracts[claude] # Python 3.10+
"""
135 changes: 135 additions & 0 deletions src/agent_contracts/adapters/claude_agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""Claude Agent SDK adapter — contract enforcement via hooks.

Usage (3 lines):
from agent_contracts.adapters.claude_agent import ContractHooks
hooks = ContractHooks.from_file("contract.yaml")
# Pass hooks.pre_tool_use and hooks.post_tool_use to ClaudeAgentOptions

Requires: pip install aicontracts[claude] (Python 3.10+)

Design: PreToolUse returns structured deny (not exception) when a tool
is unauthorized. This layers ON TOP of the SDK's own allowed_tools
mechanism — it does not replace it.
"""

from __future__ import annotations

from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Union

from agent_contracts.enforcer import ContractEnforcer, ContractViolation
from agent_contracts.loader import load_contract
from agent_contracts.types import Contract
from agent_contracts.violations import ViolationEvent


class ContractHooks:
"""Claude Agent SDK hooks that enforce an agent contract.

Generates async callables for PreToolUse and PostToolUse that can be
passed to ClaudeAgentOptions hooks configuration.

PreToolUse returns structured deny for unauthorized tools.
PostToolUse tracks tool calls against budget.
"""

def __init__(
self,
contract: Contract,
*,
violation_destination: str = "stdout",
violation_callback: Optional[Callable[[ViolationEvent], None]] = None,
) -> None:
self._enforcer = ContractEnforcer(
contract,
violation_destination=violation_destination,
violation_callback=violation_callback,
)

@classmethod
def from_file(
cls,
path: Union[str, Path],
*,
violation_destination: str = "stdout",
) -> "ContractHooks":
"""Create hooks from a contract YAML file."""
contract = load_contract(path)
return cls(contract, violation_destination=violation_destination)

@property
def enforcer(self) -> ContractEnforcer:
return self._enforcer

@property
def violations(self) -> List[ViolationEvent]:
return self._enforcer.violations

async def pre_tool_use(
self,
input_data: Dict[str, Any],
tool_use_id: Optional[str] = None,
context: Any = None,
) -> Dict[str, Any]:
"""PreToolUse hook — check authorization before tool executes.

Returns structured deny if tool is not authorized.
Returns empty dict to allow execution.
"""
tool_name = input_data.get("tool_name", "")

try:
self._enforcer.check_tool_call(tool_name)
except ContractViolation:
return {
"hookSpecificOutput": {
"hookEventName": input_data.get("hook_event_name", "PreToolUse"),
"permissionDecision": "deny",
"permissionDecisionReason": (
f"Tool '{tool_name}' not authorized by agent contract "
f"'{self._enforcer.contract.identity.name}'"
),
}
}

return {}

async def post_tool_use(
self,
input_data: Dict[str, Any],
tool_use_id: Optional[str] = None,
context: Any = None,
) -> Dict[str, Any]:
"""PostToolUse hook — observe tool completion."""
return {}

def get_hooks_config(self) -> Dict[str, Any]:
"""Return a hooks dict suitable for ClaudeAgentOptions.

Usage:
options = ClaudeAgentOptions(hooks=contract_hooks.get_hooks_config())
"""
return {
"PreToolUse": [{"hooks": [self.pre_tool_use]}],
"PostToolUse": [{"hooks": [self.post_tool_use]}],
}

def track_result(self, result_message: Any) -> None:
"""Extract cost and token usage from a ResultMessage.

Call this after the agent run completes:
async for message in query(prompt="..."):
if hasattr(message, 'total_cost_usd'):
hooks.track_result(message)
"""
cost = getattr(result_message, "total_cost_usd", None)
if cost is not None and cost > 0:
self._enforcer.add_cost(cost)

usage = getattr(result_message, "usage", None)
if isinstance(usage, dict):
input_tokens = usage.get("input_tokens", 0)
output_tokens = usage.get("output_tokens", 0)
total = input_tokens + output_tokens
if total > 0:
self._enforcer.add_tokens(total)
Loading
Loading