agenthandler 0.92.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of agenthandler might be problematic. Click here for more details.

@@ -0,0 +1,163 @@
1
+ """AgentHandler — supervision for AI agent tool execution.
2
+
3
+ Timeouts, budgets, circuit breakers, and audit for tool calls.
4
+ Works with any agent framework — just wrap your tool calls.
5
+
6
+ Usage:
7
+ from agenthandler import Supervisor, Policy
8
+
9
+ policy = Policy(max_iterations=3, tool_timeout=15, token_budget=10000)
10
+ supervisor = Supervisor(policy)
11
+
12
+ # Supervise a tool call
13
+ result = await supervisor.call("web_search", my_tool_fn, query="python async")
14
+ if result.succeeded:
15
+ print(result.output)
16
+ else:
17
+ print(result.error)
18
+ """
19
+
20
+ __version__ = "0.92.0"
21
+
22
+ from .approval import ApprovalQueue, ApprovalRequest, ApprovalStatus
23
+ from .audit import AuditEntry, AuditLog, AuditOutcome, AuditPhase, AuditSink, JsonlAuditSink
24
+ from .budget import BudgetSnapshot, BudgetTracker
25
+ from .builder import SkillBuilder
26
+ from .circuit_breaker import CircuitBreaker, CircuitState
27
+ from .connectors import MongoConnector, SqlConnector
28
+ from .context import AnchoredState, CompressedContext, CompressedHistory, ContextWindow, Turn
29
+ from .errors import AgentHandlerError
30
+ from .guardrails import (
31
+ ActiveHours,
32
+ AnomalyScorer,
33
+ CategoryRateLimit,
34
+ EnsembleDecision,
35
+ ExplanationRequired,
36
+ GoalVerifier,
37
+ GuardrailResult,
38
+ IdempotencyGuard,
39
+ IntentDriftDetector,
40
+ OutputLengthLimit,
41
+ PromptInjectionDetector,
42
+ RecursionDetector,
43
+ ResourceQuota,
44
+ SafetyClassifier,
45
+ SchemaValidator,
46
+ SemanticPiiRedactor,
47
+ UrlGuard,
48
+ )
49
+ from .observe import ModelPricing, Observer, PricingTable, ToolCallRecord, ToolMetrics
50
+ from .openclaw import OpenClawBridge, to_openclaw_manifest, to_openclaw_tool_schema
51
+ from .pipeline import Pipeline, PipelineResult
52
+ from .pipeline_poml import PipelineBuilder, parse_pipeline_poml
53
+ from .policy import Action, OnFailure, Policy, ScopeRule, ToolScope
54
+ from .redact import Redactor
55
+ from .reflection import ReflectionCycle, ReflectionLoop, ReflectionResult
56
+ from .replay import ReplayFrame, ReplaySummary, SessionReplay
57
+ from .session import SessionManager
58
+ from .skill import Skill, SkillStore, ToolDefinition, ToolParameter
59
+ from .store import (
60
+ Checkpoint,
61
+ MemoryStore,
62
+ SessionStatus,
63
+ SqliteStore,
64
+ StateStore,
65
+ validate_payload,
66
+ )
67
+ from .streams import (
68
+ KafkaStreamConnector,
69
+ KinesisStreamConnector,
70
+ RedisStreamConnector,
71
+ StreamConnector,
72
+ )
73
+ from .supervisor import SupervisedResult, Supervisor
74
+ from .triggers import DbWatchEntry, Scheduler, WebhookTrigger
75
+
76
+ __all__ = [
77
+ "Policy",
78
+ "OnFailure",
79
+ "Action",
80
+ "ScopeRule",
81
+ "ToolScope",
82
+ "Supervisor",
83
+ "SupervisedResult",
84
+ "BudgetTracker",
85
+ "BudgetSnapshot",
86
+ "CircuitBreaker",
87
+ "CircuitState",
88
+ "AuditLog",
89
+ "AuditEntry",
90
+ "AuditPhase",
91
+ "AuditOutcome",
92
+ "AuditSink",
93
+ "JsonlAuditSink",
94
+ "AgentHandlerError",
95
+ "Skill",
96
+ "SkillStore",
97
+ "ToolDefinition",
98
+ "ToolParameter",
99
+ "SkillBuilder",
100
+ "ContextWindow",
101
+ "CompressedContext",
102
+ "CompressedHistory",
103
+ "AnchoredState",
104
+ "Turn",
105
+ "OpenClawBridge",
106
+ "to_openclaw_tool_schema",
107
+ "to_openclaw_manifest",
108
+ "StateStore",
109
+ "MemoryStore",
110
+ "SqliteStore",
111
+ "Checkpoint",
112
+ "SessionStatus",
113
+ "SessionManager",
114
+ "validate_payload",
115
+ "ApprovalQueue",
116
+ "ApprovalRequest",
117
+ "ApprovalStatus",
118
+ "Redactor",
119
+ "Observer",
120
+ "ToolCallRecord",
121
+ "ToolMetrics",
122
+ "PricingTable",
123
+ "ModelPricing",
124
+ "Pipeline",
125
+ "PipelineResult",
126
+ "PipelineBuilder",
127
+ "parse_pipeline_poml",
128
+ "SqlConnector",
129
+ "MongoConnector",
130
+ "Scheduler",
131
+ "WebhookTrigger",
132
+ "DbWatchEntry",
133
+ "StreamConnector",
134
+ "KafkaStreamConnector",
135
+ "KinesisStreamConnector",
136
+ "RedisStreamConnector",
137
+ # Guardrails — deterministic
138
+ "GuardrailResult",
139
+ "SchemaValidator",
140
+ "CategoryRateLimit",
141
+ "RecursionDetector",
142
+ "IdempotencyGuard",
143
+ "ResourceQuota",
144
+ "OutputLengthLimit",
145
+ "UrlGuard",
146
+ "ActiveHours",
147
+ # Guardrails — LLM-based
148
+ "PromptInjectionDetector",
149
+ "SafetyClassifier",
150
+ "IntentDriftDetector",
151
+ "SemanticPiiRedactor",
152
+ "AnomalyScorer",
153
+ "GoalVerifier",
154
+ "ExplanationRequired",
155
+ "EnsembleDecision",
156
+ # Long-duration features
157
+ "ReflectionLoop",
158
+ "ReflectionCycle",
159
+ "ReflectionResult",
160
+ "SessionReplay",
161
+ "ReplayFrame",
162
+ "ReplaySummary",
163
+ ]
@@ -0,0 +1,135 @@
1
+ """Approval queue — human-in-the-loop confirmation for dangerous tool calls.
2
+
3
+ When a tool is in the policy's require_confirm list, the Supervisor queues
4
+ the call here instead of blocking it outright. The call stays pending until
5
+ a human approves or denies it via the REST API, dashboard, or any client
6
+ listening on the WebSocket events stream.
7
+
8
+ Usage (internal — SessionManager wires this up automatically):
9
+
10
+ queue = ApprovalQueue()
11
+ approval_id = queue.submit("delete_file", {"path": "/tmp/data"}, session_id)
12
+ # ... user reviews and approves via REST ...
13
+ queue.approve(approval_id)
14
+ # ... or denies ...
15
+ queue.deny(approval_id, reason="Too risky")
16
+ """
17
+
18
+ import secrets
19
+ import threading
20
+ from dataclasses import dataclass
21
+ from datetime import datetime, timezone
22
+ from enum import Enum
23
+ from typing import Any, Dict, List, Optional
24
+
25
+
26
+ class ApprovalStatus(Enum):
27
+ PENDING = "pending"
28
+ APPROVED = "approved"
29
+ DENIED = "denied"
30
+ EXPIRED = "expired"
31
+
32
+
33
+ @dataclass
34
+ class ApprovalRequest:
35
+ """A pending tool call awaiting human approval."""
36
+
37
+ approval_id: str
38
+ session_id: str
39
+ tool_name: str
40
+ tool_args: Dict[str, Any]
41
+ status: ApprovalStatus = ApprovalStatus.PENDING
42
+ created_at: str = ""
43
+ resolved_at: str = ""
44
+ resolved_by: str = "" # who approved/denied (for audit)
45
+ deny_reason: str = ""
46
+
47
+ def __post_init__(self) -> None:
48
+ if not self.created_at:
49
+ self.created_at = datetime.now(timezone.utc).isoformat()
50
+
51
+ def to_dict(self) -> Dict[str, Any]:
52
+ return {
53
+ "approval_id": self.approval_id,
54
+ "session_id": self.session_id,
55
+ "tool_name": self.tool_name,
56
+ "tool_args": {k: str(v)[:200] for k, v in self.tool_args.items()},
57
+ "status": self.status.value,
58
+ "created_at": self.created_at,
59
+ "resolved_at": self.resolved_at,
60
+ "resolved_by": self.resolved_by,
61
+ "deny_reason": self.deny_reason,
62
+ }
63
+
64
+
65
+ class ApprovalQueue:
66
+ """Thread-safe queue of pending tool call approvals.
67
+
68
+ One queue per SessionManager — holds approvals for all sessions.
69
+ """
70
+
71
+ def __init__(self) -> None:
72
+ self._requests: Dict[str, ApprovalRequest] = {}
73
+ self._lock = threading.Lock()
74
+
75
+ def submit(
76
+ self,
77
+ tool_name: str,
78
+ tool_args: Dict[str, Any],
79
+ session_id: str,
80
+ ) -> ApprovalRequest:
81
+ """Queue a tool call for approval. Returns the ApprovalRequest."""
82
+ req = ApprovalRequest(
83
+ approval_id=secrets.token_hex(8),
84
+ session_id=session_id,
85
+ tool_name=tool_name,
86
+ tool_args=tool_args,
87
+ )
88
+ with self._lock:
89
+ self._requests[req.approval_id] = req
90
+ return req
91
+
92
+ def get(self, approval_id: str) -> Optional[ApprovalRequest]:
93
+ with self._lock:
94
+ return self._requests.get(approval_id)
95
+
96
+ def approve(self, approval_id: str, approved_by: str = "") -> Optional[ApprovalRequest]:
97
+ """Approve a pending request. Returns the request, or None if not found."""
98
+ with self._lock:
99
+ req = self._requests.get(approval_id)
100
+ if req is None or req.status != ApprovalStatus.PENDING:
101
+ return None
102
+ req.status = ApprovalStatus.APPROVED
103
+ req.resolved_at = datetime.now(timezone.utc).isoformat()
104
+ req.resolved_by = approved_by
105
+ return req
106
+
107
+ def deny(
108
+ self, approval_id: str, reason: str = "", denied_by: str = ""
109
+ ) -> Optional[ApprovalRequest]:
110
+ """Deny a pending request. Returns the request, or None if not found."""
111
+ with self._lock:
112
+ req = self._requests.get(approval_id)
113
+ if req is None or req.status != ApprovalStatus.PENDING:
114
+ return None
115
+ req.status = ApprovalStatus.DENIED
116
+ req.deny_reason = reason
117
+ req.resolved_at = datetime.now(timezone.utc).isoformat()
118
+ req.resolved_by = denied_by
119
+ return req
120
+
121
+ def list_pending(self, session_id: Optional[str] = None) -> List[ApprovalRequest]:
122
+ """List pending approvals, optionally filtered by session."""
123
+ with self._lock:
124
+ reqs = list(self._requests.values())
125
+ if session_id is not None:
126
+ reqs = [r for r in reqs if r.session_id == session_id]
127
+ return [r for r in reqs if r.status == ApprovalStatus.PENDING]
128
+
129
+ def list_all(self, session_id: Optional[str] = None) -> List[ApprovalRequest]:
130
+ """List all approvals (any status), optionally filtered by session."""
131
+ with self._lock:
132
+ reqs = list(self._requests.values())
133
+ if session_id is not None:
134
+ reqs = [r for r in reqs if r.session_id == session_id]
135
+ return reqs
agenthandler/audit.py ADDED
@@ -0,0 +1,144 @@
1
+ """Audit logging — every supervised action gets recorded."""
2
+
3
+ import json
4
+ import sys
5
+ import threading
6
+ from abc import ABC, abstractmethod
7
+ from dataclasses import asdict, dataclass
8
+ from datetime import datetime, timezone
9
+ from enum import Enum
10
+ from pathlib import Path
11
+ from typing import IO, Any, Dict, List, Optional
12
+
13
+
14
+ class AuditPhase(Enum):
15
+ TOOL_CALL = "tool_call"
16
+ BUDGET_CHECK = "budget_check"
17
+ CIRCUIT_BREAKER = "circuit_breaker"
18
+ ITERATION_CHECK = "iteration_check"
19
+ DEAD_MAN_SWITCH = "dead_man_switch"
20
+ POLICY_CHECK = "policy_check"
21
+ SCOPE_CHECK = "scope_check"
22
+ REQUEST_START = "request_start"
23
+ REQUEST_END = "request_end"
24
+
25
+
26
+ class AuditOutcome(Enum):
27
+ ALLOWED = "allowed"
28
+ DENIED = "denied"
29
+ TIMED_OUT = "timed_out"
30
+ FAILED = "failed"
31
+ BUDGET_EXCEEDED = "budget_exceeded"
32
+ RETRIED = "retried"
33
+ INFO = "info"
34
+
35
+
36
+ @dataclass
37
+ class AuditEntry:
38
+ """A single audit log entry."""
39
+
40
+ timestamp: str
41
+ request_id: str
42
+ phase: str
43
+ outcome: str
44
+ target: Optional[str] = None
45
+ detail: Optional[str] = None
46
+ metadata: Optional[Dict[str, Any]] = None
47
+
48
+ def to_dict(self) -> Dict[str, Any]:
49
+ d = asdict(self)
50
+ return {k: v for k, v in d.items() if v is not None}
51
+
52
+ def to_json(self) -> str:
53
+ return json.dumps(self.to_dict(), default=str)
54
+
55
+
56
+ class AuditSink(ABC):
57
+ """Interface for audit log destinations."""
58
+
59
+ @abstractmethod
60
+ def write(self, entry: AuditEntry) -> None: ...
61
+
62
+
63
+ class JsonlAuditSink(AuditSink):
64
+ """Writes audit entries as JSONL to a file or stream."""
65
+
66
+ def __init__(self, writer: IO[str]):
67
+ self._writer = writer
68
+ self._lock = threading.Lock()
69
+
70
+ @classmethod
71
+ def stdout(cls) -> "JsonlAuditSink":
72
+ return cls(sys.stdout)
73
+
74
+ @classmethod
75
+ def file(cls, path: str) -> "JsonlAuditSink":
76
+ p = Path(path)
77
+ p.parent.mkdir(parents=True, exist_ok=True)
78
+ return cls(open(p, "a"))
79
+
80
+ def write(self, entry: AuditEntry) -> None:
81
+ with self._lock:
82
+ self._writer.write(entry.to_json() + "\n")
83
+ self._writer.flush()
84
+
85
+
86
+ class MemoryAuditSink(AuditSink):
87
+ """In-memory audit sink for testing."""
88
+
89
+ def __init__(self) -> None:
90
+ self._entries: List[AuditEntry] = []
91
+ self._lock = threading.Lock()
92
+
93
+ def write(self, entry: AuditEntry) -> None:
94
+ with self._lock:
95
+ self._entries.append(entry)
96
+
97
+ @property
98
+ def entries(self) -> List[AuditEntry]:
99
+ with self._lock:
100
+ return list(self._entries)
101
+
102
+ def __len__(self) -> int:
103
+ with self._lock:
104
+ return len(self._entries)
105
+
106
+
107
+ class AuditLog:
108
+ """Records audit entries for a single request."""
109
+
110
+ def __init__(self, request_id: str, sinks: Optional[List[AuditSink]] = None):
111
+ self._request_id = request_id
112
+ self._sinks = sinks or []
113
+
114
+ @classmethod
115
+ def noop(cls) -> "AuditLog":
116
+ """Audit log that discards all entries."""
117
+ return cls("noop", [])
118
+
119
+ @property
120
+ def request_id(self) -> str:
121
+ return self._request_id
122
+
123
+ def record(
124
+ self,
125
+ phase: AuditPhase,
126
+ outcome: AuditOutcome,
127
+ target: Optional[str] = None,
128
+ detail: Optional[str] = None,
129
+ metadata: Optional[Dict[str, Any]] = None,
130
+ ) -> None:
131
+ entry = AuditEntry(
132
+ timestamp=datetime.now(timezone.utc).isoformat(),
133
+ request_id=self._request_id,
134
+ phase=phase.value,
135
+ outcome=outcome.value,
136
+ target=target,
137
+ detail=detail,
138
+ metadata=metadata,
139
+ )
140
+ for sink in self._sinks:
141
+ try:
142
+ sink.write(entry)
143
+ except Exception:
144
+ pass # audit must never crash the request
agenthandler/auth.py ADDED
@@ -0,0 +1,188 @@
1
+ """Authentication providers for the REST control plane.
2
+
3
+ Supports:
4
+ - API key (Bearer token) — simple, self-issued
5
+ - OAuth2 (GitHub, Google) — for production deployments
6
+
7
+ Usage:
8
+ # API key only
9
+ app = create_app(api_key="my-secret")
10
+
11
+ # OAuth2 via env vars:
12
+ # AGENTHANDLER_OAUTH_PROVIDER=github
13
+ # AGENTHANDLER_OAUTH_CLIENT_ID=...
14
+ # AGENTHANDLER_OAUTH_CLIENT_SECRET=...
15
+ app = create_app()
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import secrets
21
+ import time
22
+ from typing import Any, Dict, Optional
23
+
24
+ try:
25
+ from fastapi import Depends, FastAPI, HTTPException, Request
26
+ from fastapi.security import (
27
+ HTTPAuthorizationCredentials,
28
+ HTTPBearer,
29
+ )
30
+ except ImportError:
31
+ pass
32
+
33
+
34
+ _bearer_scheme = HTTPBearer(auto_error=False)
35
+
36
+ # In-memory token store for OAuth sessions (maps token -> user info + expiry)
37
+ _oauth_tokens: Dict[str, Dict[str, Any]] = {}
38
+
39
+
40
+ def make_auth_dependency(
41
+ api_key: Optional[str] = None,
42
+ oauth_enabled: bool = False,
43
+ ) -> Any:
44
+ """Build a FastAPI dependency that enforces auth.
45
+
46
+ Checks in order:
47
+ 1. Bearer token matches api_key (if set)
48
+ 2. Bearer token is a valid OAuth session token (if oauth enabled)
49
+ 3. Reject
50
+
51
+ If api_key is None and oauth is disabled, all requests pass.
52
+ """
53
+
54
+ async def _check_auth(
55
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer_scheme),
56
+ ) -> Optional[Dict[str, Any]]:
57
+ # No auth configured — open access
58
+ if api_key is None and not oauth_enabled:
59
+ return None
60
+
61
+ if credentials is None:
62
+ raise HTTPException(status_code=401, detail="Missing authentication")
63
+
64
+ token = credentials.credentials
65
+
66
+ # Check API key
67
+ if api_key is not None and secrets.compare_digest(token, api_key):
68
+ return {"auth_type": "api_key"}
69
+
70
+ # Check OAuth session token
71
+ if oauth_enabled and token in _oauth_tokens:
72
+ session = _oauth_tokens[token]
73
+ if session.get("expires_at", 0) > time.time():
74
+ return session
75
+ else:
76
+ del _oauth_tokens[token] # expired
77
+
78
+ raise HTTPException(status_code=401, detail="Invalid or missing API key")
79
+
80
+ return _check_auth
81
+
82
+
83
+ def register_oauth_routes(
84
+ app: "FastAPI", provider: str, client_id: str, client_secret: str
85
+ ) -> None:
86
+ """Add OAuth2 login/callback routes to the FastAPI app.
87
+
88
+ Supports: "github", "google"
89
+ """
90
+ providers = {
91
+ "github": {
92
+ "authorize_url": "https://github.com/login/oauth/authorize",
93
+ "token_url": "https://github.com/login/oauth/access_token",
94
+ "user_url": "https://api.github.com/user",
95
+ "scope": "read:user",
96
+ },
97
+ "google": {
98
+ "authorize_url": "https://accounts.google.com/o/oauth2/v2/auth",
99
+ "token_url": "https://oauth2.googleapis.com/token",
100
+ "user_url": "https://www.googleapis.com/oauth2/v2/userinfo",
101
+ "scope": "openid email profile",
102
+ },
103
+ }
104
+
105
+ if provider not in providers:
106
+ raise ValueError(f"Unsupported OAuth provider: {provider}. Use: {list(providers.keys())}")
107
+
108
+ config = providers[provider]
109
+
110
+ @app.get("/auth/login")
111
+ def oauth_login(request: Request) -> Dict[str, Any]:
112
+ """Redirect URL for OAuth2 login."""
113
+ callback_url = str(request.base_url) + "auth/callback"
114
+ state = secrets.token_hex(16)
115
+ url = (
116
+ f"{config['authorize_url']}?"
117
+ f"client_id={client_id}&"
118
+ f"redirect_uri={callback_url}&"
119
+ f"scope={config['scope']}&"
120
+ f"state={state}"
121
+ )
122
+ return {"login_url": url, "state": state}
123
+
124
+ @app.get("/auth/callback")
125
+ async def oauth_callback(code: str, state: str = "") -> Dict[str, Any]:
126
+ """OAuth2 callback — exchange code for token, create session."""
127
+ try:
128
+ import httpx
129
+ except ImportError:
130
+ raise HTTPException(
131
+ status_code=500,
132
+ detail="httpx required for OAuth. pip install httpx",
133
+ )
134
+
135
+ # Exchange code for access token
136
+ async with httpx.AsyncClient() as client:
137
+ token_resp = await client.post(
138
+ config["token_url"],
139
+ data={
140
+ "client_id": client_id,
141
+ "client_secret": client_secret,
142
+ "code": code,
143
+ },
144
+ headers={"Accept": "application/json"},
145
+ )
146
+ if token_resp.status_code != 200:
147
+ raise HTTPException(status_code=401, detail="OAuth token exchange failed")
148
+ token_data = token_resp.json()
149
+ access_token = token_data.get("access_token")
150
+ if not access_token:
151
+ raise HTTPException(status_code=401, detail="No access token in response")
152
+
153
+ # Get user info
154
+ user_resp = await client.get(
155
+ config["user_url"],
156
+ headers={"Authorization": f"Bearer {access_token}"},
157
+ )
158
+ if user_resp.status_code != 200:
159
+ raise HTTPException(status_code=401, detail="Failed to fetch user info")
160
+ user_info = user_resp.json()
161
+
162
+ # Create a session token
163
+ session_token = secrets.token_hex(32)
164
+ _oauth_tokens[session_token] = {
165
+ "auth_type": "oauth",
166
+ "provider": provider,
167
+ "user": user_info.get("login") or user_info.get("email", "unknown"),
168
+ "user_info": user_info,
169
+ "expires_at": time.time() + 86400, # 24 hours
170
+ }
171
+
172
+ return {
173
+ "token": session_token,
174
+ "user": _oauth_tokens[session_token]["user"],
175
+ "expires_in": 86400,
176
+ }
177
+
178
+ @app.get("/auth/me")
179
+ async def auth_me(
180
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer_scheme),
181
+ ) -> Dict[str, Any]:
182
+ """Get info about the current authenticated user."""
183
+ if credentials is None:
184
+ raise HTTPException(status_code=401, detail="Not authenticated")
185
+ session = _oauth_tokens.get(credentials.credentials)
186
+ if session and session.get("expires_at", 0) > time.time():
187
+ return {"user": session["user"], "provider": session.get("provider")}
188
+ raise HTTPException(status_code=401, detail="Invalid or expired token")