Skip to content
Open
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
131 changes: 113 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ When an AI agent crashes mid-task, what happens on restart? Without effect-log,

## The Core Idea

Every tool declares its **effect kind** at registration time. This drives all recovery behavior:
Every tool has an **effect kind** that drives all recovery behavior:

| EffectKind | Recovery (completed) | Recovery (crashed) |
|---|---|---|
Expand All @@ -18,36 +18,117 @@ Every tool declares its **effect kind** at registration time. This drives all re

## Quick Start

### Auto mode — just pass functions

```python
from effect_log import EffectKind, EffectLog, ToolDef
from effect_log import EffectLog

log = EffectLog.auto("task-001", tools=[search_db, send_email, upsert_record])
log.execute("search_db", {"query": "Q4 revenue"}) # auto → ReadOnly
log.execute("send_email", {"to": "ceo@co.com", ...}) # auto → IrreversibleWrite
log.execute("upsert_record", {"id": "r-001", ...}) # auto → IdempotentWrite
```

def send_email(args):
return smtp.send(args["to"], args["subject"], args["body"])
### Manual mode — explicit ToolDef for full control

```python
from effect_log import EffectKind, EffectLog, ToolDef

tools = [
ToolDef("read_file", EffectKind.ReadOnly, read_file),
ToolDef("send_email", EffectKind.IrreversibleWrite, send_email),
ToolDef("upsert", EffectKind.IdempotentWrite, upsert_record),
]
log = EffectLog.manual("task-001", tools=tools, storage="sqlite:///effects.db")
```

### Recovery — just add `recover=True`

log = EffectLog(execution_id="task-001", tools=tools, storage="sqlite:///effects.db")
log.execute("read_file", {"path": "/tmp/report.csv"})
log.execute("send_email", {"to": "ceo@co.com", "subject": "Report", "body": "..."})
log.execute("upsert", {"id": "report-001", "data": data})
```python
log = EffectLog.auto("task-001", tools=[search_db, send_email, upsert_record],
storage="sqlite:///effects.db", recover=True)
log.execute("search_db", {"query": "Q4 revenue"}) # Replayed (fresh data)
log.execute("send_email", {"to": "ceo@co.com", ...}) # Sealed — NOT re-sent
log.execute("upsert_record", {"id": "r-001", ...}) # Replayed (idempotent)
```

Recovery — just add `recover=True`, re-run the same steps:
### Override when needed

If the classifier gets something wrong, override just that tool:

```python
log = EffectLog(execution_id="task-001", tools=tools, storage="sqlite:///effects.db", recover=True)
log.execute("read_file", {"path": "/tmp/report.csv"}) # Replayed (fresh data)
log.execute("send_email", {"to": "ceo@co.com", ...}) # Sealed — NOT re-sent
log.execute("upsert", {"id": "report-001", ...}) # Replayed (idempotent)
from effect_log import EffectKind, EffectLog

log = EffectLog.auto("task-001",
tools=[search_db, send_email, process_order],
overrides={"process_order": EffectKind.IdempotentWrite}
)
```

### Inspect classifications

```python
from effect_log import classify_tools

report = classify_tools([search_db, send_email, process_order])
print(report)
# search_db -> ReadOnly (0.50) name
# send_email -> IrreversibleWrite (0.50) name
# process_order -> IrreversibleWrite (0.00) default (no signals)!!!

# Apply with corrections
tools = report.apply(overrides={"process_order": EffectKind.IdempotentWrite})
log = EffectLog("task-001", tools=tools)
```

### Hybrid mode (default constructor)

The default constructor accepts a mix of callables and ToolDefs:

```python
from effect_log import EffectLog, ToolDef, EffectKind

log = EffectLog("task-001", tools=[
search_db, # auto-classified
ToolDef("send_email", EffectKind.IrreversibleWrite, send_email), # explicit
])
```

### Decorators

```python
from effect_log import tool, auto_tool

@tool(effect=EffectKind.ReadOnly) # explicit
def read_file(args): ...

@tool() # auto-classified
def search_db(args): ...

@auto_tool # shorthand for @tool()
def fetch_data(args): ...
```

## Auto-Classification

effect-log classifies tools using a 4-layer weighted heuristic:

| Layer | Signal | Weight | Example |
|---|---|---|---|
| **Name prefix** | `func.__name__` matched against prefix→kind map | 0.50 | `search_` → ReadOnly |
| **Docstring keywords** | `inspect.getdoc()` scanned for keyword families | 0.25 | "irreversible" → IrreversibleWrite |
| **Parameter names** | `inspect.signature()` parameter names | 0.15 | `to`, `recipient` → IrreversibleWrite |
| **Source AST** | `inspect.getsource()` for HTTP/SDK patterns | 0.10 | `requests.post()` → IrreversibleWrite |

**Safety guarantees:**
- Low confidence → defaults to `IrreversibleWrite` (never re-executes ambiguous tools)
- Compensatable auto-downgrades to `IrreversibleWrite` (requires compensation function)
- Explicit always wins: `overrides=`, `ToolDef(kind)`, `@tool(EffectKind.X)` bypass classification
- All classifications logged (`effect_log.classify` logger)

## Framework Integration

Built-in middleware for major agent frameworks:
Built-in middleware for major agent frameworks. All middleware now accepts raw callables with auto-classification:

| Framework | Middleware | Entry Point |
|---|---|---|
Expand All @@ -56,7 +137,22 @@ Built-in middleware for major agent frameworks:
| **CrewAI** | `effect_log.middleware.crewai` | `effect_logged_crew`, `effect_logged_tool` |
| **Pydantic AI** | `effect_log.middleware.pydantic_ai` | `effect_logged_agent`, `EffectLogToolset` |
| **Anthropic Claude API** | `effect_log.middleware.anthropic` | `effect_logged_tool_executor`, `process_tool_calls` |
| **Bub** | `effect_log.middleware.bub` | `effect_logged_registry`, `effect_logged_tool` |
| **Bub** | `effect_log.middleware.bub` | `EffectLoggedToolExecutor`, `effect_logged_agent` |

Middleware `make_tooldefs()` / `make_tools()` now accepts raw callables alongside spec dicts:

```python
from effect_log.middleware.anthropic import make_tooldefs

# Before: always needed explicit effect
make_tooldefs([
{"func": search_db, "effect": EffectKind.ReadOnly},
{"func": send_email, "effect": EffectKind.IrreversibleWrite},
])

# After: just pass functions
make_tooldefs([search_db, send_email])
```

See [`examples/`](examples/) for runnable demos:

Expand Down Expand Up @@ -95,11 +191,10 @@ pytest tests/ -v

- [x] Core library — WAL engine, recovery engine, SQLite + in-memory backends
- [x] Python bindings — PyO3 + maturin
- [x] Framework middleware — LangGraph, OpenAI Agents SDK, CrewAI, Pydantic AI, Anthropic Claude API
- [x] Framework middleware — LangGraph, OpenAI Agents SDK, CrewAI, Pydantic AI, Bub
- [x] Framework middleware — LangGraph, OpenAI Agents SDK, CrewAI, Pydantic AI, Anthropic Claude API, Bub
- [x] Auto-classification — infer effect kind from function name, docstring, parameters, and source AST
- [ ] TypeScript bindings — napi-rs, Vercel AI SDK
- [ ] Additional backends — RocksDB, S3, Restate journal
- [ ] Auto-classification — infer effect kind from HTTP methods / API metadata

## Inspiration

Expand Down
185 changes: 180 additions & 5 deletions bindings/python/python/effect_log/__init__.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,202 @@
"""effect-log: Semantic side-effect tracking for AI agents."""

from effect_log.effect_log_native import EffectKind, EffectLog, ToolDef
from enum import Enum

__all__ = ["EffectKind", "EffectLog", "ToolDef", "tool", "middleware"]
from effect_log.effect_log_native import (
EffectKind,
EffectLog as _NativeEffectLog,
ToolDef,
)
from effect_log.classify import (
classify_effect_kind,
classify_from_name,
classify_tools,
classify_with_llm,
)


def tool(effect: EffectKind, compensate=None):
class ClassifyMode(Enum):
"""Controls how tools are classified in an EffectLog.

AUTO — all tools are raw callables; effect kinds are inferred automatically.
MANUAL — all tools are explicit ToolDef instances; no auto-classification.
HYBRID — mixed: callables are auto-classified, ToolDefs pass through as-is.
"""

AUTO = "auto"
MANUAL = "manual"
HYBRID = "hybrid"


__all__ = [
"ClassifyMode",
"EffectKind",
"EffectLog",
"ToolDef",
"tool",
"auto_tool",
"classify_tools",
"classify_effect_kind",
"classify_from_name",
"classify_with_llm",
"middleware",
]


def _wrap_callable(func):
"""Wrap a bare callable so it receives **kwargs from args dict."""

def adapted(args, _fn=func):
return _fn(**args)

return adapted


class EffectLog:
"""Python wrapper around _NativeEffectLog with auto-classification.

Accepts raw callables alongside ToolDef instances. Raw callables are
auto-classified using heuristic analysis. Use overrides= to correct
any misclassifications.

Args:
execution_id: Unique identifier for this execution.
tools: List of ToolDef instances, callables, or a mix.
storage: Storage backend ("memory" or "sqlite:///path").
recover: Whether to recover from a previous execution.
overrides: Optional dict mapping function name -> EffectKind
to override auto-classification.
mode: ClassifyMode controlling validation (default HYBRID).
"""

def __init__(
self,
execution_id: str,
tools: list,
storage: str = "memory",
recover: bool = False,
overrides: dict[str, EffectKind] | None = None,
mode: ClassifyMode = ClassifyMode.HYBRID,
):
if mode is ClassifyMode.MANUAL and overrides:
raise ValueError(
"overrides= is not supported in MANUAL mode. "
"Remove overrides or use HYBRID mode."
)

overrides = overrides or {}
processed = []
for t in tools:
if isinstance(t, ToolDef):
if mode is ClassifyMode.AUTO:
raise TypeError(
"In AUTO mode, pass raw callables instead of ToolDef. "
"Use MANUAL or HYBRID mode for explicit ToolDef."
)
processed.append(t)
elif callable(t):
if mode is ClassifyMode.MANUAL:
raise TypeError(
"In MANUAL mode, all tools must be ToolDef instances. "
"Use EffectLog.auto() or HYBRID mode for raw callables."
)
name = getattr(t, "__name__", str(t))
kind = overrides.get(name)
if kind is None:
kind = classify_effect_kind(t, name).effect_kind
processed.append(ToolDef(name, kind, _wrap_callable(t)))
else:
raise TypeError(f"Expected ToolDef or callable, got {type(t).__name__}")
self._inner = _NativeEffectLog(execution_id, processed, storage, recover)

@classmethod
def auto(
cls,
execution_id: str,
tools: list,
storage: str = "memory",
recover: bool = False,
overrides: dict[str, EffectKind] | None = None,
) -> "EffectLog":
"""Create an EffectLog in AUTO mode — all tools are raw callables."""
return cls(
execution_id=execution_id,
tools=tools,
storage=storage,
recover=recover,
overrides=overrides,
mode=ClassifyMode.AUTO,
)

@classmethod
def manual(
cls,
execution_id: str,
tools: list,
storage: str = "memory",
recover: bool = False,
) -> "EffectLog":
"""Create an EffectLog in MANUAL mode — all tools must be ToolDef."""
return cls(
execution_id=execution_id,
tools=tools,
storage=storage,
recover=recover,
mode=ClassifyMode.MANUAL,
)

def execute(self, tool_name: str, args: dict):
"""Execute a tool through the effect-log WAL."""
return self._inner.execute(tool_name, args)

def history(self) -> list[dict]:
"""Get execution history."""
return self._inner.history()


def tool(effect=None, compensate=None):
"""Decorator to register a function as an effect-logged tool.

Supports both ``@tool`` (no parens) and ``@tool()`` / ``@tool(EffectKind.X)``.

Args:
effect: The EffectKind classification for this tool.
effect: The EffectKind classification. If None, auto-classified.
compensate: Optional compensation function for Compensatable effects.

Returns:
A ToolDef wrapping the function.
"""
# Handle @tool without parens: effect will be the decorated function
if callable(effect):
return auto_tool(effect)

def decorator(func):
kind = effect
if kind is None:
kind = classify_effect_kind(func).effect_kind
return ToolDef(
name=func.__name__,
effect_kind=effect,
effect_kind=kind,
func=func,
compensate=compensate,
)

return decorator


def auto_tool(func):
"""Convenience decorator: auto-classifies effect kind from function metadata.

Equivalent to @tool() with no arguments.

Usage:
@auto_tool
def search_db(args):
return db.query(args["query"])
"""
kind = classify_effect_kind(func).effect_kind
return ToolDef(
name=func.__name__,
effect_kind=kind,
func=func,
)
Loading
Loading