tide-runtime 0.2.5__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.
tide/__init__.py ADDED
@@ -0,0 +1,250 @@
1
+ """Tide runtime governance for agent tools with real side effects.
2
+
3
+ Wrap the tools your agent uses to touch business systems. Before each call a
4
+ deterministic policy decides allow / allow_with_log / require_approval / block;
5
+ every call, approval, and outcome is appended to a local JSONL ledger that the
6
+ `tide ledger` CLI reads.
7
+
8
+ from tide import configure, controlled_tool, record_outcome
9
+
10
+ configure(agent_id="crm-agent", workflow_id="lead-sync", environment="prod")
11
+
12
+ @controlled_tool(name="crm.update_lead", risk_class="internal_write",
13
+ capture=["lead_id"])
14
+ def update_lead(lead_id, status):
15
+ ...
16
+
17
+ Zero dependencies, Python >= 3.8. Payloads are hashed by default; `capture`
18
+ opts specific fields into the ledger.
19
+ """
20
+
21
+ import functools
22
+ import inspect
23
+ import os
24
+ import time
25
+
26
+ from . import policy as _policy
27
+ from . import store as _store
28
+
29
+ __version__ = "0.1.0"
30
+
31
+ __all__ = [
32
+ "configure",
33
+ "controlled_tool",
34
+ "record_outcome",
35
+ "LedgerBlocked",
36
+ "LedgerDenied",
37
+ "LedgerPending",
38
+ ]
39
+
40
+
41
+ class LedgerBlocked(Exception):
42
+ """Policy decision was block; the tool did not run."""
43
+
44
+
45
+ class LedgerDenied(Exception):
46
+ """A human denied the approval request; the tool did not run."""
47
+
48
+
49
+ class LedgerPending(Exception):
50
+ """Approval is pending. Approve via `tide ledger approve <id>` and call again."""
51
+
52
+ def __init__(self, approval_id, message):
53
+ super().__init__(message)
54
+ self.approval_id = approval_id
55
+
56
+
57
+ _config = {
58
+ "project": os.environ.get("TIDE_LEDGER_DIR", ".tide/ledger"),
59
+ "agent_id": "agent",
60
+ "workflow_id": None,
61
+ "environment": None,
62
+ "trace_id": None,
63
+ "on_approval": "wait",
64
+ "approval_timeout": 300,
65
+ "approval_poll": 2,
66
+ }
67
+
68
+
69
+ def configure(**kwargs):
70
+ """Set run-level config; starts a new trace unless trace_id is passed."""
71
+ unknown = set(kwargs) - set(_config)
72
+ if unknown:
73
+ raise TypeError("unknown configure() options: %s" % ", ".join(sorted(unknown)))
74
+ _config.update(kwargs)
75
+ if not kwargs.get("trace_id"):
76
+ _config["trace_id"] = _store.new_id()
77
+ return dict(_config)
78
+
79
+
80
+ def _trace_id():
81
+ if not _config["trace_id"]:
82
+ _config["trace_id"] = _store.new_id()
83
+ return _config["trace_id"]
84
+
85
+
86
+ def _environment(pol):
87
+ return (
88
+ _config["environment"]
89
+ or os.environ.get("TIDE_ENV")
90
+ or os.environ.get("LEDGER_ENV")
91
+ or (pol or {}).get("environment")
92
+ or "dev"
93
+ )
94
+
95
+
96
+ def _bound_args(fn, args, kwargs):
97
+ try:
98
+ bound = inspect.signature(fn).bind(*args, **kwargs)
99
+ bound.apply_defaults()
100
+ return dict(bound.arguments)
101
+ except TypeError:
102
+ return {"args": list(args), "kwargs": kwargs}
103
+
104
+
105
+ def _wait_for_decision(project, approval_id):
106
+ deadline = time.time() + _config["approval_timeout"]
107
+ while time.time() < deadline:
108
+ state = _store.approval_state(project, approval_id)
109
+ if state == "approved":
110
+ return "approved"
111
+ if state == "denied":
112
+ return "denied"
113
+ time.sleep(_config["approval_poll"])
114
+ return "timeout"
115
+
116
+
117
+ def record_outcome(status, trace_id=None, workflow_id=None, human_corrected=False,
118
+ failure_tag=None, source="api", project=None):
119
+ """Record what the workflow achieved: usually success or failure."""
120
+ project = project or _config["project"]
121
+ record = {
122
+ "outcome_id": _store.new_id(),
123
+ "trace_id": trace_id or _trace_id(),
124
+ "workflow_id": workflow_id or _config["workflow_id"],
125
+ "status": status,
126
+ "source": source,
127
+ "human_corrected": bool(human_corrected),
128
+ "failure_tag": failure_tag,
129
+ "created_at": _store.now_iso(),
130
+ }
131
+ _store.append_jsonl(project, _store.OUTCOMES_FILE, record)
132
+ return record
133
+
134
+
135
+ def controlled_tool(name, version="0.0.0", risk_class="internal_write", owner=None,
136
+ capture=None, cost_usd=None):
137
+ """Decorator: gate a tool behind policy and record each call."""
138
+ capture = list(capture or [])
139
+
140
+ def decorator(fn):
141
+ @functools.wraps(fn)
142
+ def wrapper(*args, **kwargs):
143
+ project = _config["project"]
144
+ pol = _store.load_policy(project) or dict(_policy.DEFAULT_POLICY)
145
+ env = _environment(pol)
146
+ trace_id = _trace_id()
147
+ named = _bound_args(fn, args, kwargs)
148
+ resolved_risk_class = risk_class(named) if callable(risk_class) else risk_class
149
+ input_hash = _store.canonical_hash(named)
150
+ captured = {k: named[k] for k in capture if k in named}
151
+ decision, reason = _policy.decide(pol, name, resolved_risk_class, env)
152
+
153
+ event = {
154
+ "event_id": _store.new_id(),
155
+ "trace_id": trace_id,
156
+ "workflow_id": _config["workflow_id"],
157
+ "agent_id": _config["agent_id"],
158
+ "tool": name,
159
+ "tool_version": version,
160
+ "owner": owner,
161
+ "risk_class": resolved_risk_class,
162
+ "environment": env,
163
+ "input_hash": input_hash,
164
+ "captured": captured,
165
+ "policy_decision": decision,
166
+ "policy_reason": reason,
167
+ "approval_id": None,
168
+ "approval_status": None,
169
+ "output_hash": None,
170
+ "latency_ms": None,
171
+ "cost_usd": cost_usd,
172
+ "error_code": None,
173
+ "created_at": _store.now_iso(),
174
+ }
175
+
176
+ if decision == "block":
177
+ event["error_code"] = "blocked_by_policy"
178
+ _store.append_jsonl(project, _store.EVENTS_FILE, event)
179
+ raise LedgerBlocked("%s blocked by policy: %s" % (name, reason))
180
+
181
+ if decision == "require_approval":
182
+ grant = _store.find_grant(project, name, input_hash)
183
+ if grant:
184
+ _store.append_jsonl(project, _store.APPROVALS_FILE, {
185
+ "type": "consumed",
186
+ "approval_id": grant,
187
+ "event_id": event["event_id"],
188
+ "created_at": _store.now_iso(),
189
+ })
190
+ event["approval_id"] = grant
191
+ event["approval_status"] = "approved"
192
+ else:
193
+ approval_id = _store.new_id()
194
+ _store.append_jsonl(project, _store.APPROVALS_FILE, {
195
+ "type": "request",
196
+ "approval_id": approval_id,
197
+ "event_id": event["event_id"],
198
+ "trace_id": event["trace_id"],
199
+ "tool": name,
200
+ "tool_version": version,
201
+ "risk_class": resolved_risk_class,
202
+ "input_hash": input_hash,
203
+ "input_summary": captured,
204
+ "policy_reason": reason,
205
+ "agent_id": _config["agent_id"],
206
+ "workflow_id": _config["workflow_id"],
207
+ "created_at": _store.now_iso(),
208
+ })
209
+ event["approval_id"] = approval_id
210
+ hint = "approve with: tide ledger approve %s --project %s" % (approval_id[:8], project)
211
+ if _config["on_approval"] == "raise":
212
+ event["approval_status"] = "pending"
213
+ _store.append_jsonl(project, _store.EVENTS_FILE, event)
214
+ raise LedgerPending(approval_id, "%s needs approval - %s" % (name, hint))
215
+ result_state = _wait_for_decision(project, approval_id)
216
+ if result_state == "denied":
217
+ event["approval_status"] = "denied"
218
+ event["error_code"] = "approval_denied"
219
+ _store.append_jsonl(project, _store.EVENTS_FILE, event)
220
+ raise LedgerDenied("%s was denied by a human approver" % name)
221
+ if result_state == "timeout":
222
+ event["approval_status"] = "pending"
223
+ event["error_code"] = "approval_timeout"
224
+ _store.append_jsonl(project, _store.EVENTS_FILE, event)
225
+ raise LedgerPending(approval_id, "%s approval timed out - %s" % (name, hint))
226
+ _store.append_jsonl(project, _store.APPROVALS_FILE, {
227
+ "type": "consumed",
228
+ "approval_id": approval_id,
229
+ "event_id": event["event_id"],
230
+ "created_at": _store.now_iso(),
231
+ })
232
+ event["approval_status"] = "approved"
233
+
234
+ start = time.time()
235
+ try:
236
+ result = fn(*args, **kwargs)
237
+ except Exception as exc:
238
+ event["latency_ms"] = int((time.time() - start) * 1000)
239
+ event["error_code"] = type(exc).__name__
240
+ _store.append_jsonl(project, _store.EVENTS_FILE, event)
241
+ raise
242
+ event["latency_ms"] = int((time.time() - start) * 1000)
243
+ event["output_hash"] = _store.canonical_hash(result)
244
+ _store.append_jsonl(project, _store.EVENTS_FILE, event)
245
+ return result
246
+
247
+ wrapper.__tide__ = {"name": name, "version": version, "risk_class": risk_class, "owner": owner}
248
+ return wrapper
249
+
250
+ return decorator
tide/policy.py ADDED
@@ -0,0 +1,43 @@
1
+ """Deterministic policy engine: risk class x environment -> decision."""
2
+
3
+ RISK_CLASSES = (
4
+ "read_only",
5
+ "draft_only",
6
+ "internal_write",
7
+ "customer_visible",
8
+ "money_movement",
9
+ "destructive",
10
+ )
11
+
12
+ DECISIONS = ("allow", "allow_with_log", "require_approval", "block")
13
+
14
+ DEFAULT_POLICY = {
15
+ "version": 1,
16
+ "environment": "dev",
17
+ "decisions": {
18
+ "read_only": {"dev": "allow", "prod": "allow"},
19
+ "draft_only": {"dev": "allow", "prod": "allow_with_log"},
20
+ "internal_write": {"dev": "allow_with_log", "prod": "require_approval"},
21
+ "customer_visible": {"dev": "require_approval", "prod": "require_approval"},
22
+ "money_movement": {"dev": "require_approval", "prod": "require_approval"},
23
+ "destructive": {"dev": "require_approval", "prod": "block"},
24
+ },
25
+ "tools": {},
26
+ }
27
+
28
+
29
+ def decide(policy, tool_name, risk_class, environment):
30
+ """Return (decision, reason). Per-tool override wins over the class table."""
31
+ tool_rule = (policy.get("tools") or {}).get(tool_name)
32
+ if tool_rule:
33
+ decision = tool_rule.get("decision")
34
+ if decision in DECISIONS:
35
+ return decision, tool_rule.get("reason") or ("per-tool override for " + tool_name)
36
+ return "block", "invalid per-tool override for %s - failing closed" % tool_name
37
+ table = (policy.get("decisions") or {}).get(risk_class)
38
+ if not isinstance(table, dict):
39
+ return "block", "unknown risk class '%s' - failing closed" % risk_class
40
+ decision = table.get(environment, table.get("prod"))
41
+ if decision not in DECISIONS:
42
+ return "block", "no valid decision for %s in '%s' - failing closed" % (risk_class, environment)
43
+ return decision, "%s in %s -> %s" % (risk_class, environment, decision)
tide/store.py ADDED
@@ -0,0 +1,101 @@
1
+ """File contract for the Tide ledger directory.
2
+
3
+ policy.json policy config
4
+ events.jsonl one record per controlled tool call
5
+ approvals.jsonl request / decision / consumed records
6
+ outcomes.jsonl workflow outcome events
7
+ """
8
+
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import uuid
13
+ from datetime import datetime, timezone
14
+
15
+ POLICY_FILE = "policy.json"
16
+ EVENTS_FILE = "events.jsonl"
17
+ APPROVALS_FILE = "approvals.jsonl"
18
+ OUTCOMES_FILE = "outcomes.jsonl"
19
+
20
+
21
+ def now_iso():
22
+ return datetime.now(timezone.utc).isoformat()
23
+
24
+
25
+ def new_id():
26
+ return uuid.uuid4().hex
27
+
28
+
29
+ def canonical_hash(obj):
30
+ blob = json.dumps(obj, sort_keys=True, separators=(",", ":"), default=str)
31
+ return "sha256:" + hashlib.sha256(blob.encode("utf-8")).hexdigest()
32
+
33
+
34
+ def ensure_project(project):
35
+ os.makedirs(project, exist_ok=True)
36
+ return project
37
+
38
+
39
+ def load_policy(project):
40
+ path = os.path.join(project, POLICY_FILE)
41
+ if not os.path.exists(path):
42
+ return None
43
+ with open(path, "r", encoding="utf-8") as f:
44
+ return json.load(f)
45
+
46
+
47
+ def append_jsonl(project, filename, record):
48
+ ensure_project(project)
49
+ with open(os.path.join(project, filename), "a", encoding="utf-8") as f:
50
+ f.write(json.dumps(record, sort_keys=True, default=str) + "\n")
51
+
52
+
53
+ def read_jsonl(project, filename):
54
+ path = os.path.join(project, filename)
55
+ if not os.path.exists(path):
56
+ return []
57
+ records = []
58
+ with open(path, "r", encoding="utf-8") as f:
59
+ for line in f:
60
+ line = line.strip()
61
+ if not line:
62
+ continue
63
+ try:
64
+ records.append(json.loads(line))
65
+ except ValueError:
66
+ continue
67
+ return records
68
+
69
+
70
+ def approval_state(project, approval_id):
71
+ state = None
72
+ for r in read_jsonl(project, APPROVALS_FILE):
73
+ if r.get("approval_id") != approval_id:
74
+ continue
75
+ kind = r.get("type")
76
+ if kind == "request":
77
+ state = "pending"
78
+ elif kind == "decision":
79
+ state = "approved" if r.get("decision") == "approved" else "denied"
80
+ elif kind == "consumed":
81
+ state = "consumed"
82
+ return state
83
+
84
+
85
+ def find_grant(project, tool_name, input_hash):
86
+ """Find an approved, unconsumed approval bound to this exact tool and input."""
87
+ requests = {}
88
+ for r in read_jsonl(project, APPROVALS_FILE):
89
+ aid = r.get("approval_id")
90
+ kind = r.get("type")
91
+ if kind == "request":
92
+ requests[aid] = {"record": r, "state": "pending"}
93
+ elif kind == "decision" and aid in requests:
94
+ requests[aid]["state"] = "approved" if r.get("decision") == "approved" else "denied"
95
+ elif kind == "consumed" and aid in requests:
96
+ requests[aid]["state"] = "consumed"
97
+ for aid, info in requests.items():
98
+ req = info["record"]
99
+ if info["state"] == "approved" and req.get("tool") == tool_name and req.get("input_hash") == input_hash:
100
+ return aid
101
+ return None
@@ -0,0 +1,37 @@
1
+ Metadata-Version: 2.4
2
+ Name: tide-runtime
3
+ Version: 0.2.5
4
+ Summary: Tide runtime policy, approvals, and earned-use ledger for AI agent tools.
5
+ Project-URL: Repository, https://github.com/rippletideco/rippletide-package
6
+ Requires-Python: >=3.8
7
+ Description-Content-Type: text/markdown
8
+
9
+ # Tide Runtime SDK
10
+
11
+ Python SDK for wrapping production agent tools with Tide policy, approvals, and
12
+ earned-use logging.
13
+
14
+ ```bash
15
+ pip install tide-runtime
16
+ tide ledger init
17
+ ```
18
+
19
+ ```python
20
+ from tide import configure, controlled_tool, record_outcome
21
+
22
+ configure(agent_id="crm-agent", workflow_id="lead-sync", environment="prod")
23
+
24
+ @controlled_tool(name="crm.update_lead", risk_class="internal_write", capture=["lead_id"])
25
+ def update_lead(lead_id, status):
26
+ return {"lead_id": lead_id, "status": status}
27
+
28
+ try:
29
+ update_lead("L-1", "qualified")
30
+ record_outcome("success")
31
+ except Exception:
32
+ record_outcome("failure")
33
+ raise
34
+ ```
35
+
36
+ Default files live in `.tide/ledger/`. Override with `TIDE_LEDGER_DIR` or
37
+ `configure(project="...")`.
@@ -0,0 +1,7 @@
1
+ tide/__init__.py,sha256=QanIyZb3Z-hJDvpyUlJfQSZ8QlcDGHXT5-MPYkHHycc,9478
2
+ tide/policy.py,sha256=Ds5JF4ZaurCFMut9jT11o-5Mw6k3EolaqW8a4rcjDWs,1760
3
+ tide/store.py,sha256=XwMlrzgfe9zfiSEYOpeEDSPnsMMtu9WfYr40bclBc9o,2990
4
+ tide_runtime-0.2.5.dist-info/METADATA,sha256=CN3ZhDft-miHeWr_XCIH76MKBO5qXmBKPihO0QW8cF8,1016
5
+ tide_runtime-0.2.5.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
6
+ tide_runtime-0.2.5.dist-info/top_level.txt,sha256=VWFFwJ9JGbJhNRC7ewPF2GjEHeIMv-bnDWco7XP7xMc,5
7
+ tide_runtime-0.2.5.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ tide