proactive-gate 0.1.2 → 0.2.0
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.
- package/README.md +223 -11
- package/README.tr.md +134 -3
- package/dist/src/adapters/ai-sdk.d.ts +35 -0
- package/dist/src/adapters/ai-sdk.js +17 -0
- package/dist/src/adapters/langchain.d.ts +32 -0
- package/dist/src/adapters/langchain.js +20 -0
- package/dist/src/adapters/mastra.d.ts +25 -0
- package/dist/src/adapters/mastra.js +16 -0
- package/dist/src/adapters/openai-agents.d.ts +31 -0
- package/dist/src/adapters/openai-agents.js +16 -0
- package/dist/src/checks.d.ts +71 -12
- package/dist/src/checks.js +149 -24
- package/dist/src/cli.d.ts +14 -1
- package/dist/src/cli.js +154 -24
- package/dist/src/conformance.d.ts +42 -0
- package/dist/src/conformance.js +83 -0
- package/dist/src/gate.d.ts +13 -5
- package/dist/src/gate.js +73 -28
- package/dist/src/index.d.ts +6 -3
- package/dist/src/index.js +3 -1
- package/dist/src/init.d.ts +16 -0
- package/dist/src/init.js +115 -0
- package/dist/src/policy.d.ts +8 -0
- package/dist/src/policy.js +74 -0
- package/dist/src/presets.d.ts +9 -0
- package/dist/src/presets.js +45 -0
- package/dist/src/stores.js +5 -8
- package/dist/src/types.d.ts +74 -2
- package/package.json +57 -9
- package/dist/test/gate.test.d.ts +0 -1
- package/dist/test/gate.test.js +0 -261
package/README.md
CHANGED
|
@@ -37,11 +37,24 @@ if (decision.allowed && (await gate.commit(decision, { user, candidate }))) {
|
|
|
37
37
|
}
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
Or start from a policy file and the wiring for your framework, in one command:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
npx proactive-gate init --preset usTcpa --framework mastra
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
That writes `proactive-gate.policy.json` with the ten checks in order, appends the
|
|
47
|
+
preset you named, and prints the preset's own source next to the few lines that plug
|
|
48
|
+
the gate into that framework. `npx proactive-gate init --list` shows the fourteen
|
|
49
|
+
platform and legal presets and the four frameworks.
|
|
50
|
+
|
|
40
51
|
Zero dependencies. TypeScript. Node 20 or newer. Framework-agnostic: the gate sits
|
|
41
52
|
between "the model produced something" and "the user's phone buzzed", whichever
|
|
42
53
|
model or framework produced it. Examples: [`examples/vercel-ai-sdk.ts`](examples/vercel-ai-sdk.ts),
|
|
43
54
|
[`examples/mastra.ts`](examples/mastra.ts), [`examples/langgraph.ts`](examples/langgraph.ts), and a
|
|
44
|
-
replayable policy in [`examples/policy.
|
|
55
|
+
replayable policy in [`examples/policy.json`](examples/policy.json). Docs and a browser playground:
|
|
56
|
+
[bubblegunn.github.io/proactive-gate](https://bubblegunn.github.io/proactive-gate/). API reference:
|
|
57
|
+
[`docs/api`](docs/api/README.md). Python: [`python/`](python/README.md).
|
|
45
58
|
|
|
46
59
|
## What a decision looks like
|
|
47
60
|
|
|
@@ -67,7 +80,10 @@ replayable policy in [`examples/policy.js`](examples/policy.js). API reference:
|
|
|
67
80
|
}
|
|
68
81
|
```
|
|
69
82
|
|
|
70
|
-
<p align="center"><img src="assets/trace.
|
|
83
|
+
<p align="center"><img src="docs/assets/trace.svg" width="900" alt="Two real decision traces side by side: candidate a1 rejected by quietHours after eight checks, candidate a5 allowed after thirteen, each check with its outcome, reason and cost"></p>
|
|
84
|
+
|
|
85
|
+
The figure is drawn from the replay's `--json` output by `node scripts/trace-svg.mjs`, every
|
|
86
|
+
line verbatim; the left decision is the one printed above.
|
|
71
87
|
|
|
72
88
|
With one gate and a logged reason, "why was the user not told about this" has an
|
|
73
89
|
answer. With checks scattered through a pipeline, the honest answer is "somewhere,
|
|
@@ -138,6 +154,102 @@ Mark a check `nonRejecting: true` when it may only move timing or narrow surface
|
|
|
138
154
|
gate then ignores a reject from it and says so in the trace, so a bug in a timing model
|
|
139
155
|
cannot silence a user.
|
|
140
156
|
|
|
157
|
+
## A policy is data
|
|
158
|
+
|
|
159
|
+
The same checks as a JSON document, so a product team can change the rules without a
|
|
160
|
+
deploy and the same file runs in TypeScript, in Python, in the CLI and in the
|
|
161
|
+
[playground](https://bubblegunn.github.io/proactive-gate/playground/):
|
|
162
|
+
|
|
163
|
+
```json
|
|
164
|
+
{
|
|
165
|
+
"specVersion": "1.0.0",
|
|
166
|
+
"checks": [
|
|
167
|
+
{ "id": "consent" },
|
|
168
|
+
{ "id": "snooze", "defer": true },
|
|
169
|
+
{ "id": "quietHours", "priorityFloor": "high" },
|
|
170
|
+
{ "preset": "usTcpa" },
|
|
171
|
+
{ "id": "utilityFloor", "costFalseAlarm": 1, "costMissedHelp": 2, "shadow": true },
|
|
172
|
+
{ "id": "dailyBudget", "limit": 3, "bypassPriority": "critical", "nearLimit": 0.67 }
|
|
173
|
+
]
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
const gate = createGate({ policy: JSON.parse(await readFile("policy.json", "utf8")), store });
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Each entry names a check `id` or a `preset` plus that check's options. An unknown id throws
|
|
182
|
+
and names the known ones. `compilePolicy` is exported for callers that want the check list,
|
|
183
|
+
and the schema is at [`spec/schema/policy.schema.json`](spec/schema/policy.schema.json).
|
|
184
|
+
`examples/policy.js` stays as the escape hatch for checks that need functions.
|
|
185
|
+
|
|
186
|
+
## Defer, shadow mode, near-limit notes and hooks
|
|
187
|
+
|
|
188
|
+
A check can `defer` instead of rejecting: the decision has `allowed: false`, `deferredBy` and
|
|
189
|
+
`retryAt`, and the caller knows when to try again. `snooze({ defer: true })` is the built-in
|
|
190
|
+
example.
|
|
191
|
+
|
|
192
|
+
A check with `shadow: true` runs and is traced with its real outcome, but cannot stop the
|
|
193
|
+
message; its id lands in `decision.shadowed`. Ship a new rule in shadow for a week, count how
|
|
194
|
+
often it would have fired, then turn it on.
|
|
195
|
+
|
|
196
|
+
Budgets report `nearLimit: { used, limit }` on the pass that reaches the threshold (80 percent
|
|
197
|
+
by default), listed under `decision.nearLimit`, so a dashboard can show who is about to go
|
|
198
|
+
quiet.
|
|
199
|
+
|
|
200
|
+
`hooks: { before, after, error, finally }` observe every check with its cost in milliseconds;
|
|
201
|
+
a hook that throws is routed to `error` and never changes the decision. `examples/otel.ts`
|
|
202
|
+
turns them into one span per check. Every decision has an `id`, and `commit` is idempotent on
|
|
203
|
+
it: a retry after a timeout does not consume a second unit.
|
|
204
|
+
|
|
205
|
+
## Optional checks, fed by your own model
|
|
206
|
+
|
|
207
|
+
Both ship off. They read numbers the caller puts on the candidate.
|
|
208
|
+
|
|
209
|
+
- `utilityFloor({ costFalseAlarm, costMissedHelp })` acts only when `candidate.pAccept` clears
|
|
210
|
+
`tau = cFA / (cFA + pNeed * cFN)` (`pNeed` defaults to 1) and skips when there is no
|
|
211
|
+
`pAccept`. This is Horvitz's expected-utility rule with the PRISM threshold.
|
|
212
|
+
- `boundedDeferral({ lambda, interruptCost, staleness, boundSeconds })` never rejects. When
|
|
213
|
+
`candidate.busy` is true it moves `deliverAt` to `now + t*`, with
|
|
214
|
+
`t* = min(bound, lambda * interruptCost / (2 * staleness))`; the defaults give 116 seconds.
|
|
215
|
+
|
|
216
|
+
Neither check ships a model, a cost or a probability. `costFalseAlarm`, `costMissedHelp`,
|
|
217
|
+
`interruptCost` and `staleness` are yours to measure, and the package has no opinion about
|
|
218
|
+
what an interruption costs your users. The rules come from Eric Horvitz's work on
|
|
219
|
+
attention-sensitive alerting and bounded deferral; the field measurement people usually
|
|
220
|
+
reach for is [Iqbal and Horvitz, "Disruption and recovery of computing tasks", CHI
|
|
221
|
+
2007](https://erichorvitz.com/CHI_2007_Iqbal_Horvitz.pdf), which logged real users and put
|
|
222
|
+
the return to a suspended task in the region of 11 to 16 minutes. The widely repeated "23
|
|
223
|
+
minutes 15 seconds" figure is not from a peer-reviewed paper and is not used here.
|
|
224
|
+
|
|
225
|
+
## Presets: platform quotas and legal limits, with sources
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
import { presets } from "proactive-gate/presets";
|
|
229
|
+
const gate = createGate({ checks: [checks.consent(), ...presets.kakaoBrandMessage()] });
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
| preset | encodes |
|
|
233
|
+
|---|---|
|
|
234
|
+
| `lineMessagingApi({ plan })` | monthly push budget by LINE plan: 200, 5,000 or 30,000 |
|
|
235
|
+
| `wechatSubscriptionMessage` | one message per subscription opt-in |
|
|
236
|
+
| `wechatCustomerService` | within 48 h of the user's last message, at most 5 |
|
|
237
|
+
| `wechatTemplateMessage` | only after a user action, 3 templates a day |
|
|
238
|
+
| `wecomAppMessage` | 30 a minute and 1,000 an hour per member |
|
|
239
|
+
| `kakaoAlimtalk` | consent only; AlimTalk has no time-of-day rule |
|
|
240
|
+
| `kakaoBrandMessage` | advertising consent, 08:00 to 20:50 Asia/Seoul |
|
|
241
|
+
| `krNetworkAct50` | advertising consent, plus night consent for 21:00 to 08:00 local |
|
|
242
|
+
| `jpAntiSpamLaw` | opt-in |
|
|
243
|
+
| `cnMinorMode` | for minors: 06:00 to 22:00 Asia/Shanghai and one a day |
|
|
244
|
+
| `usTcpa` | 08:00 to 21:00 at the user's local time (47 CFR 64.1200) |
|
|
245
|
+
| `euEprivacy` | marketing consent with the soft opt-in for existing customers |
|
|
246
|
+
| `telegramBot` | 1 a second and 20 a minute per chat |
|
|
247
|
+
| `slackApp` | 1 a second per channel |
|
|
248
|
+
|
|
249
|
+
Each preset carries `sources` (the pages the numbers come from) and a `note` on what it leaves
|
|
250
|
+
out. Reviewable defaults, not legal advice: several official sources disagree with each other,
|
|
251
|
+
and the note says which value was chosen and why.
|
|
252
|
+
|
|
141
253
|
## The budget is enforced at commit, not at evaluate
|
|
142
254
|
|
|
143
255
|
Two instances can both evaluate a candidate for the same user, both see four of
|
|
@@ -210,22 +322,115 @@ to keep once the checks are scattered:
|
|
|
210
322
|
- A policy can be replayed over a day of real candidates before it ships, and a non-rejecting
|
|
211
323
|
check cannot reject even if a bug makes it try.
|
|
212
324
|
|
|
325
|
+
Those are claims, so the repository runs them. `npm run bench:compare` replays a
|
|
326
|
+
committed day of 21 candidates for 7 users through `bench/naive.mjs`, an honest
|
|
327
|
+
hand-rolled policy of five `if` statements, and through a gate built from
|
|
328
|
+
`bench/fixtures/policy.json`:
|
|
329
|
+
|
|
330
|
+
```
|
|
331
|
+
gate: 11 sent, 10 stopped
|
|
332
|
+
hand-rolled: 13 sent, 8 stopped
|
|
333
|
+
|
|
334
|
+
6 disagreements, and none of them is a matter of taste:
|
|
335
|
+
a5 a critical alert: the gate lets priority bypass the cap, the cap in the if statements does not
|
|
336
|
+
b1 a two-day-old account: the gate holds normal messages back for a week, the if statements never knew
|
|
337
|
+
c1 the user pressed snooze: the gate defers to when it ends, the if statements have no snooze
|
|
338
|
+
e1 three dismissals of this type: the gate is silent for a week, the if statements do not track outcomes
|
|
339
|
+
f4 01:00 in Tokyo, a new local day: the gate resets the cap, the UTC-day key stays on yesterday for nine more hours
|
|
340
|
+
g4 18:00 in Los Angeles, still the same local day: the UTC-day key already rolled, so the cap pays out twice
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
The hand-rolled policy is not a straw man. It checks consent, enabled, mute, quiet
|
|
344
|
+
hours and a daily cap, which is what actually gets written, and it takes the three
|
|
345
|
+
shortcuts that actually get taken: a fixed UTC offset per zone, the cap keyed by the
|
|
346
|
+
UTC calendar day, and the cap read then written. `test/naive.test.mjs` pins each one
|
|
347
|
+
against a real instant:
|
|
348
|
+
|
|
349
|
+
- The clocks change. At `2026-11-01T12:30:00Z` New York has left daylight time, so it is
|
|
350
|
+
07:30 there and inside quiet hours; an offset captured in the summer computes 08:00 and
|
|
351
|
+
sends. That is twice a year, for every zone that observes it.
|
|
352
|
+
- The day boundary is local. The same UTC-day key silences the Tokyo user for the nine
|
|
353
|
+
hours between local midnight and 09:00, and hands the Los Angeles user a second full
|
|
354
|
+
budget at 17:00 while it is still their afternoon.
|
|
355
|
+
- Two deliveries are in flight. Read, compare, write lets both take the last slot, and the
|
|
356
|
+
counter still reads 2 afterwards, so nothing looks wrong. `commit()` takes the unit with
|
|
357
|
+
an atomic increment and returns `false` to the loser.
|
|
358
|
+
|
|
213
359
|
A feature-flag system does a different job better: rolling a behaviour out to a percentage
|
|
214
360
|
of users, per-tenant overrides, and an audit trail of who flipped what. Use flags to decide
|
|
215
361
|
whether the gate runs at all, and the gate to decide whether this message reaches this
|
|
216
362
|
person now.
|
|
217
363
|
|
|
218
|
-
##
|
|
364
|
+
## Adapters
|
|
219
365
|
|
|
220
|
-
|
|
|
366
|
+
| subpath | framework | where the gate sits |
|
|
221
367
|
|---|---|---|
|
|
222
|
-
| Vercel AI SDK | [`examples/vercel-ai-sdk.ts`](examples/vercel-ai-sdk.ts)
|
|
223
|
-
| Mastra | [`examples/mastra.ts`](examples/mastra.ts)
|
|
224
|
-
|
|
|
368
|
+
| `proactive-gate/ai-sdk` | Vercel AI SDK | answers a tool's `needsApproval` ([`examples/vercel-ai-sdk.ts`](examples/vercel-ai-sdk.ts); runnable offline: [`examples/ai-sdk/`](examples/ai-sdk/)) |
|
|
369
|
+
| `proactive-gate/mastra` | Mastra | an output processor before the send ([`examples/mastra.ts`](examples/mastra.ts); runnable offline: [`examples/mastra/`](examples/mastra/)) |
|
|
370
|
+
| `proactive-gate/langchain` | LangChain | middleware around the send tool |
|
|
371
|
+
| `proactive-gate/openai-agents` | OpenAI Agents | a guardrail |
|
|
372
|
+
| `npx proactive-gate hook` | Claude Code | a `PreToolUse` hook ([`examples/claude-code-hook.json`](examples/claude-code-hook.json)) |
|
|
373
|
+
|
|
374
|
+
The adapters are typed against the shape of a call, not against the framework package, so
|
|
375
|
+
nothing else has to be installed. Each denies with the gate's reason and commits the budget on
|
|
376
|
+
approval. The pattern is the same everywhere: the model decides whether there is something to
|
|
377
|
+
say, `gate.evaluate` decides whether it may be said now, and `gate.commit` runs right before
|
|
378
|
+
the message leaves. [`examples/langgraph.ts`](examples/langgraph.ts) shows the same thing
|
|
379
|
+
inside a LangGraph node.
|
|
380
|
+
|
|
381
|
+
Two of the examples run without the framework installed and without a network: `node
|
|
382
|
+
examples/mastra/run.mjs` makes the same `processOutputResult` call Mastra makes, over a day of
|
|
383
|
+
candidates with the clock taken from each line, and `node examples/ai-sdk/run.mjs` answers a
|
|
384
|
+
day of tool-approval requests, one of which is a critical alert that a legal window (the TCPA
|
|
385
|
+
preset) still refuses. Both are part of `npm run examples` and of the test suite.
|
|
386
|
+
|
|
387
|
+
## Python
|
|
388
|
+
|
|
389
|
+
```
|
|
390
|
+
pip install proactive-gate
|
|
391
|
+
```
|
|
225
392
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
393
|
+
```python
|
|
394
|
+
from proactive_gate import Gate
|
|
395
|
+
gate = Gate.from_policy(policy) # the same policy.json
|
|
396
|
+
decision = gate.evaluate(inp)
|
|
397
|
+
if decision.allowed and gate.commit(decision, inp): send(...)
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
`python/` is a sibling, not a port that drifts: it passes every fixture under `spec/fixtures`
|
|
401
|
+
through a sync `Gate` and an `AsyncGate` (Redis over `redis.asyncio`), with mypy strict, on
|
|
402
|
+
Python 3.11 and 3.13 in CI. See [`python/README.md`](python/README.md).
|
|
403
|
+
|
|
404
|
+
## Properties, not just examples
|
|
405
|
+
|
|
406
|
+
`test/properties.test.ts` generates gates, users and candidates from a seeded
|
|
407
|
+
32-bit PRNG and asserts what has to hold for all of them, rather than for the
|
|
408
|
+
cases someone thought of:
|
|
409
|
+
|
|
410
|
+
- The trace is always a prefix of the declared check order. Nothing is skipped,
|
|
411
|
+
nothing is reordered, every check reports exactly once, and a stopped decision
|
|
412
|
+
ends on the check that stopped it with a reason attached.
|
|
413
|
+
- A check marked `nonRejecting` cannot stop a decision even when it returns a
|
|
414
|
+
rejection on purpose.
|
|
415
|
+
- However many deliveries race, `commit()` hands out exactly `min(racers, limit)`
|
|
416
|
+
units, and replaying one decision any number of times spends one.
|
|
417
|
+
- `MemoryStore` and `SqliteStore` answer the same random sequence of `get`, `set`,
|
|
418
|
+
`incr`, `del` and clock movement identically, TTLs included.
|
|
419
|
+
|
|
420
|
+
The generator is forty lines because the package has no dependencies; a property
|
|
421
|
+
library would shrink failures better. Each assertion prints its seed, so a failure
|
|
422
|
+
reproduces exactly. The race property was checked against a mutant: rewriting
|
|
423
|
+
`consume` as read-then-write, the shortcut in `bench/naive.mjs`, makes it fail.
|
|
424
|
+
|
|
425
|
+
## The spec, and writing a second implementation
|
|
426
|
+
|
|
427
|
+
[`spec/SPEC.md`](spec/SPEC.md) states the behaviour as numbered requirements, and
|
|
428
|
+
[`spec/fixtures`](spec/fixtures) holds language-neutral cases: the DST edge in
|
|
429
|
+
America/New_York, Pacific/Apia, a wall-clock case in 2031, atomic commit, the ISO week,
|
|
430
|
+
deferral, shadow mode, the optional checks and four presets. The TypeScript tests and the
|
|
431
|
+
Python tests both run all of them; `npx proactive-gate replay --fixtures spec/fixtures` runs
|
|
432
|
+
them from the command line. A third implementation starts from the fixtures, not from this
|
|
433
|
+
source.
|
|
229
434
|
|
|
230
435
|
## Performance
|
|
231
436
|
|
|
@@ -275,11 +480,18 @@ Tian Pan's
|
|
|
275
480
|
essay argues the same case from the product side and suggests a daily cap of three
|
|
276
481
|
to five; `defaultChecks({ dailyLimit })` defaults to five.
|
|
277
482
|
|
|
483
|
+
The shape has older relatives. Matrix push rules are an ordered list where the first matching
|
|
484
|
+
rule decides. Android notification channels and iOS interruption levels give the user a
|
|
485
|
+
per-type switch and a priority floor that bypasses quiet time. Horvitz's work on mixed
|
|
486
|
+
initiative supplied the two optional checks. This package puts those ideas in one list with a
|
|
487
|
+
trace, and adds the part they leave out: the budget consumed at send time.
|
|
488
|
+
|
|
278
489
|
## Development
|
|
279
490
|
|
|
280
491
|
```
|
|
281
492
|
npm ci
|
|
282
|
-
npm test
|
|
493
|
+
npm test # tsc build, spec-lint, then node:test over dist/test
|
|
494
|
+
cd python && pytest # the Python sibling against the same fixtures
|
|
283
495
|
```
|
|
284
496
|
|
|
285
497
|
MIT.
|
package/README.tr.md
CHANGED
|
@@ -33,7 +33,9 @@ Sıfır bağımlılık. TypeScript. Node 20 ya da üstü. Framework'ten bağıms
|
|
|
33
33
|
şey üretti" ile "kullanıcının telefonu titredi" arasında durur; hangi model ya da framework
|
|
34
34
|
üretmiş olursa olsun. Örnekler: [`examples/vercel-ai-sdk.ts`](examples/vercel-ai-sdk.ts),
|
|
35
35
|
[`examples/mastra.ts`](examples/mastra.ts) ve yeniden oynatılabilir bir politika olarak
|
|
36
|
-
[`examples/policy.
|
|
36
|
+
[`examples/policy.json`](examples/policy.json). Dokümantasyon ve tarayıcıda oyun alanı:
|
|
37
|
+
[bubblegunn.github.io/proactive-gate](https://bubblegunn.github.io/proactive-gate/). Python:
|
|
38
|
+
[`python/`](python/README.md).
|
|
37
39
|
|
|
38
40
|
## Bir karar neye benzer
|
|
39
41
|
|
|
@@ -59,7 +61,10 @@ Sıfır bağımlılık. TypeScript. Node 20 ya da üstü. Framework'ten bağıms
|
|
|
59
61
|
}
|
|
60
62
|
```
|
|
61
63
|
|
|
62
|
-
<p align="center"><img src="assets/trace.
|
|
64
|
+
<p align="center"><img src="docs/assets/trace.svg" width="900" alt="Yan yana iki gerçek karar izi: a1 adayı sekiz kontrolden sonra sessiz saatlerde reddedildi, a5 adayı on üç kontrolden sonra geçti; her kontrol sonucu, gerekçesi ve maliyetiyle"></p>
|
|
65
|
+
|
|
66
|
+
Şekil, replay komutunun `--json` çıktısından `node scripts/trace-svg.mjs` ile çizilir; her satır
|
|
67
|
+
olduğu gibi alınmıştır.
|
|
63
68
|
|
|
64
69
|
Tek kapı ve kayıtlı bir gerekçe ile "kullanıcıya bu neden söylenmedi" sorusunun bir cevabı
|
|
65
70
|
olur. Kontroller bir boru hattına dağılmışken dürüst cevap "bir yerde bir şey false döndü"
|
|
@@ -123,6 +128,125 @@ Bir kontrol yalnızca zamanlamayı taşıyabiliyor ya da yüzeyleri daraltabiliy
|
|
|
123
128
|
`nonRejecting: true` işaretleyin; kapı ondan gelen bir reddi yok sayar ve bunu izde söyler,
|
|
124
129
|
böylece bir zamanlama modelindeki hata bir kullanıcıyı susturamaz.
|
|
125
130
|
|
|
131
|
+
## Politika bir veridir
|
|
132
|
+
|
|
133
|
+
Aynı kontroller bir JSON belgesi olarak da yazılabilir; ürün ekibi kuralları dağıtım yapmadan
|
|
134
|
+
değiştirir ve aynı dosya TypeScript'te, Python'da, CLI'da ve
|
|
135
|
+
[oyun alanında](https://bubblegunn.github.io/proactive-gate/playground/) çalışır:
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
{
|
|
139
|
+
"specVersion": "1.0.0",
|
|
140
|
+
"checks": [
|
|
141
|
+
{ "id": "consent" },
|
|
142
|
+
{ "id": "snooze", "defer": true },
|
|
143
|
+
{ "id": "quietHours", "priorityFloor": "high" },
|
|
144
|
+
{ "preset": "usTcpa" },
|
|
145
|
+
{ "id": "utilityFloor", "costFalseAlarm": 1, "costMissedHelp": 2, "shadow": true },
|
|
146
|
+
{ "id": "dailyBudget", "limit": 3, "bypassPriority": "critical", "nearLimit": 0.67 }
|
|
147
|
+
]
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
const gate = createGate({ policy: JSON.parse(await readFile("policy.json", "utf8")), store });
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Her girdi bir kontrol `id`'si ya da bir `preset` ve o kontrolün seçeneklerini taşır. Bilinmeyen
|
|
156
|
+
bir id hata fırlatır ve bilinenleri sayar. Şema
|
|
157
|
+
[`spec/schema/policy.schema.json`](spec/schema/policy.schema.json) dosyasındadır;
|
|
158
|
+
`examples/policy.js`, fonksiyon gerektiren kontroller için kaçış yolu olarak durur.
|
|
159
|
+
|
|
160
|
+
## Erteleme, gölge modu, sınıra yakınlık notları ve kancalar
|
|
161
|
+
|
|
162
|
+
Bir kontrol reddetmek yerine `defer` diyebilir: karar `allowed: false`, `deferredBy` ve
|
|
163
|
+
`retryAt` taşır, çağıran ne zaman tekrar deneyeceğini bilir. `snooze({ defer: true })` yerleşik
|
|
164
|
+
örnektir.
|
|
165
|
+
|
|
166
|
+
`shadow: true` işaretli bir kontrol çalışır ve izde gerçek sonucuyla görünür, ama mesajı
|
|
167
|
+
durduramaz; id'si `decision.shadowed` listesine düşer. Yeni bir kuralı bir hafta gölgede
|
|
168
|
+
çalıştırın, kaç kez ateşleyeceğini sayın, sonra açın.
|
|
169
|
+
|
|
170
|
+
Bütçeler eşiğe (varsayılan yüzde 80) ulaşan geçişte `nearLimit: { used, limit }` bildirir;
|
|
171
|
+
`decision.nearLimit` altında listelenir, böylece bir pano kimin susmak üzere olduğunu gösterir.
|
|
172
|
+
|
|
173
|
+
`hooks: { before, after, error, finally }` her kontrolü milisaniye maliyetiyle gözler; hata
|
|
174
|
+
fırlatan bir kanca `error` kancasına yönlendirilir ve kararı asla değiştirmez.
|
|
175
|
+
`examples/otel.ts` bunları kontrol başına bir span'e çevirir. Her kararın bir `id`'si vardır ve
|
|
176
|
+
`commit` bu id üzerinde tekrarlanabilir: zaman aşımından sonraki bir yeniden deneme ikinci
|
|
177
|
+
bir birim tüketmez.
|
|
178
|
+
|
|
179
|
+
## İsteğe bağlı, kendi modelinizin beslediği kontroller
|
|
180
|
+
|
|
181
|
+
İkisi de kapalı gelir; adayın üzerine çağıranın koyduğu sayıları okurlar.
|
|
182
|
+
|
|
183
|
+
- `utilityFloor({ costFalseAlarm, costMissedHelp })` yalnızca `candidate.pAccept` değeri
|
|
184
|
+
`tau = cFA / (cFA + pNeed * cFN)` eşiğini geçtiğinde konuşur (`pNeed` varsayılanı 1);
|
|
185
|
+
`pAccept` yoksa atlar. Bu, Horvitz'in beklenen fayda kuralı ve PRISM eşiğidir.
|
|
186
|
+
- `boundedDeferral({ lambda, interruptCost, staleness, boundSeconds })` asla reddetmez.
|
|
187
|
+
`candidate.busy` doğruysa `deliverAt` değerini `now + t*` yapar;
|
|
188
|
+
`t* = min(bound, lambda * interruptCost / (2 * staleness))`, varsayılanlar 116 saniye verir.
|
|
189
|
+
|
|
190
|
+
## Hazır paketler: platform kotaları ve yasal sınırlar, kaynaklarıyla
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
import { presets } from "proactive-gate/presets";
|
|
194
|
+
const gate = createGate({ checks: [checks.consent(), ...presets.kakaoBrandMessage()] });
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
| paket | ne kodlar |
|
|
198
|
+
|---|---|
|
|
199
|
+
| `lineMessagingApi({ plan })` | LINE planına göre aylık push bütçesi: 200, 5.000 ya da 30.000 |
|
|
200
|
+
| `wechatSubscriptionMessage` | abonelik onayı başına bir mesaj |
|
|
201
|
+
| `wechatCustomerService` | kullanıcının son mesajından sonraki 48 saat içinde en çok 5 |
|
|
202
|
+
| `wechatTemplateMessage` | yalnızca kullanıcı eyleminden sonra, günde 3 şablon |
|
|
203
|
+
| `wecomAppMessage` | üye başına dakikada 30 ve saatte 1.000 |
|
|
204
|
+
| `kakaoAlimtalk` | yalnızca rıza; AlimTalk'ta saat kuralı yok |
|
|
205
|
+
| `kakaoBrandMessage` | reklam rızası, 08:00 ile 20:50 Asia/Seoul |
|
|
206
|
+
| `krNetworkAct50` | reklam rızası, ayrıca 21:00 ile 08:00 yerel saat için gece rızası |
|
|
207
|
+
| `jpAntiSpamLaw` | opt-in |
|
|
208
|
+
| `cnMinorMode` | reşit olmayanlar için: 06:00 ile 22:00 Asia/Shanghai ve günde bir |
|
|
209
|
+
| `usTcpa` | kullanıcının yerel saatiyle 08:00 ile 21:00 (47 CFR 64.1200) |
|
|
210
|
+
| `euEprivacy` | pazarlama rızası, mevcut müşteriler için yumuşak opt-in |
|
|
211
|
+
| `telegramBot` | sohbet başına saniyede 1 ve dakikada 20 |
|
|
212
|
+
| `slackApp` | kanal başına saniyede 1 |
|
|
213
|
+
|
|
214
|
+
Her paket `sources` (sayıların geldiği sayfalar) ve neyi dışarıda bıraktığını söyleyen bir
|
|
215
|
+
`note` taşır. Gözden geçirilebilir varsayılanlar, hukuki tavsiye değil: birkaç resmi kaynak
|
|
216
|
+
birbiriyle çelişir ve not hangi değerin neden seçildiğini söyler.
|
|
217
|
+
|
|
218
|
+
## Adaptörler
|
|
219
|
+
|
|
220
|
+
| alt yol | framework | kapı nerede durur |
|
|
221
|
+
|---|---|---|
|
|
222
|
+
| `proactive-gate/ai-sdk` | Vercel AI SDK | bir aracın `needsApproval` sorusunu yanıtlar (çevrimdışı çalışan örnek: [`examples/ai-sdk/`](examples/ai-sdk/)) |
|
|
223
|
+
| `proactive-gate/mastra` | Mastra | gönderimden önce bir çıktı işlemcisi (çevrimdışı çalışan örnek: [`examples/mastra/`](examples/mastra/)) |
|
|
224
|
+
| `proactive-gate/langchain` | LangChain | gönderim aracının çevresinde middleware |
|
|
225
|
+
| `proactive-gate/openai-agents` | OpenAI Agents | bir guardrail |
|
|
226
|
+
| `npx proactive-gate hook` | Claude Code | bir `PreToolUse` kancası ([`examples/claude-code-hook.json`](examples/claude-code-hook.json)) |
|
|
227
|
+
|
|
228
|
+
Adaptörler framework paketine değil, çağrının biçimine göre tiplenmiştir; başka bir şey
|
|
229
|
+
kurmak gerekmez. Her biri kapının gerekçesiyle reddeder ve onayda bütçeyi tüketir.
|
|
230
|
+
|
|
231
|
+
## Python
|
|
232
|
+
|
|
233
|
+
```
|
|
234
|
+
pip install proactive-gate
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
`python/` sapan bir port değil, bir kardeştir: `spec/fixtures` altındaki her senaryoyu senkron
|
|
238
|
+
`Gate` ve `AsyncGate` (Redis, `redis.asyncio` üzerinden) ile geçer; mypy strict, CI'da Python
|
|
239
|
+
3.11 ve 3.13. Bkz. [`python/README.md`](python/README.md).
|
|
240
|
+
|
|
241
|
+
## Sözleşme ve ikinci bir uygulama yazmak
|
|
242
|
+
|
|
243
|
+
[`spec/SPEC.md`](spec/SPEC.md) davranışı numaralı gereksinimler olarak yazar;
|
|
244
|
+
[`spec/fixtures`](spec/fixtures) dile bağlı olmayan senaryoları tutar: America/New_York'taki
|
|
245
|
+
yaz saati kenarı, Pacific/Apia, 2031'de bir duvar saati senaryosu, atomik commit, ISO haftası,
|
|
246
|
+
erteleme, gölge modu, isteğe bağlı kontroller ve dört hazır paket. TypeScript ve Python
|
|
247
|
+
testleri hepsini çalıştırır; `npx proactive-gate replay --fixtures spec/fixtures` komut
|
|
248
|
+
satırından çalıştırır. Üçüncü bir uygulama bu kaynaktan değil, senaryolardan başlar.
|
|
249
|
+
|
|
126
250
|
## Bütçe evaluate'te değil, commit'te uygulanır
|
|
127
251
|
|
|
128
252
|
İki örnek aynı kullanıcı için aynı adayı değerlendirebilir, ikisi de beşte dördün kullanıldığını
|
|
@@ -206,11 +330,18 @@ yazısında savunulmuş kararlardır. Tian Pan'ın
|
|
|
206
330
|
yazısı aynı davayı ürün tarafından savunur ve günde üç ile beş arası bir tavan önerir;
|
|
207
331
|
`defaultChecks({ dailyLimit })` varsayılanı beştir.
|
|
208
332
|
|
|
333
|
+
Biçimin daha eski akrabaları var. Matrix push kuralları, ilk eşleşen kuralın karar verdiği
|
|
334
|
+
sıralı bir listedir. Android bildirim kanalları ve iOS kesinti seviyeleri kullanıcıya tür
|
|
335
|
+
başına bir anahtar ve sessiz saati aşan bir öncelik tabanı verir. Horvitz'in karma girişim
|
|
336
|
+
çalışmaları iki isteğe bağlı kontrolü sağladı. Bu paket o fikirleri izli tek bir listeye
|
|
337
|
+
koyar ve onların dışarıda bıraktığı parçayı ekler: gönderim anında tüketilen bütçe.
|
|
338
|
+
|
|
209
339
|
## Geliştirme
|
|
210
340
|
|
|
211
341
|
```
|
|
212
342
|
npm ci
|
|
213
|
-
npm test
|
|
343
|
+
npm test # tsc build, spec-lint, then node:test over dist/test
|
|
344
|
+
cd python && pytest # the Python sibling against the same fixtures
|
|
214
345
|
```
|
|
215
346
|
|
|
216
347
|
MIT.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vercel AI SDK tool approvals. Give the send tool `needsApproval: true` and
|
|
3
|
+
* answer each approval request through the gate:
|
|
4
|
+
*
|
|
5
|
+
* const approve = gateToolApproval({ gate, toInput: (call) => call.input.gate });
|
|
6
|
+
* const { approved, reason } = await approve(call);
|
|
7
|
+
* // then addToolApprovalResponse({ id: call.approvalId, approved, reason })
|
|
8
|
+
*
|
|
9
|
+
* Typed against the shape of a tool call, not against the `ai` package, so
|
|
10
|
+
* nothing needs to be installed to build proactive-gate.
|
|
11
|
+
*/
|
|
12
|
+
import type { Gate } from "../gate.js";
|
|
13
|
+
import type { EvaluateInput } from "../types.js";
|
|
14
|
+
export interface ToolApprovalRequest {
|
|
15
|
+
toolName?: string;
|
|
16
|
+
toolCallId?: string;
|
|
17
|
+
approvalId?: string;
|
|
18
|
+
input?: unknown;
|
|
19
|
+
}
|
|
20
|
+
export interface ToolApprovalResult {
|
|
21
|
+
approved: boolean;
|
|
22
|
+
reason?: string;
|
|
23
|
+
}
|
|
24
|
+
export declare function gateToolApproval<T extends ToolApprovalRequest>(options: {
|
|
25
|
+
gate: Gate;
|
|
26
|
+
toInput: (call: T) => EvaluateInput;
|
|
27
|
+
/** Also consume the budget on approval. Default true. */
|
|
28
|
+
commit?: boolean;
|
|
29
|
+
}): (call: T) => Promise<ToolApprovalResult>;
|
|
30
|
+
export declare function describe(decision: {
|
|
31
|
+
rejectedBy?: string;
|
|
32
|
+
deferredBy?: string;
|
|
33
|
+
retryAt?: Date;
|
|
34
|
+
reason?: string;
|
|
35
|
+
}): string;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function gateToolApproval(options) {
|
|
2
|
+
const commit = options.commit ?? true;
|
|
3
|
+
return async (call) => {
|
|
4
|
+
const input = options.toInput(call);
|
|
5
|
+
const decision = await options.gate.evaluate(input);
|
|
6
|
+
if (!decision.allowed)
|
|
7
|
+
return { approved: false, reason: describe(decision) };
|
|
8
|
+
if (commit && !(await options.gate.commit(decision, input)))
|
|
9
|
+
return { approved: false, reason: "a budget was exhausted at commit" };
|
|
10
|
+
return { approved: true, ...(decision.deliverAt ? { reason: `deliver at ${decision.deliverAt.toISOString()}` } : {}) };
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export function describe(decision) {
|
|
14
|
+
if (decision.deferredBy)
|
|
15
|
+
return `deferred by ${decision.deferredBy} until ${decision.retryAt?.toISOString()}: ${decision.reason}`;
|
|
16
|
+
return `rejected by ${decision.rejectedBy}: ${decision.reason}`;
|
|
17
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LangChain middleware that wraps tool calls. For the tools listed in `tools`
|
|
3
|
+
* the gate decides first; a rejection returns a tool message carrying the
|
|
4
|
+
* reason instead of running the tool.
|
|
5
|
+
*
|
|
6
|
+
* createAgent({ tools: [sendMessage], middleware: [gateMiddleware({ gate, tools: ["send_message"], toInput: (req) => req.toolCall.args.gate })] })
|
|
7
|
+
*/
|
|
8
|
+
import type { Gate } from "../gate.js";
|
|
9
|
+
import type { EvaluateInput } from "../types.js";
|
|
10
|
+
export interface ToolCallRequest {
|
|
11
|
+
toolCall: {
|
|
12
|
+
name: string;
|
|
13
|
+
id?: string;
|
|
14
|
+
args: Record<string, unknown>;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export interface ToolMessageLike {
|
|
18
|
+
type: "tool";
|
|
19
|
+
content: string;
|
|
20
|
+
tool_call_id: string;
|
|
21
|
+
status: "success" | "error";
|
|
22
|
+
}
|
|
23
|
+
export interface ToolCallMiddleware<R extends ToolCallRequest = ToolCallRequest, T = unknown> {
|
|
24
|
+
name: string;
|
|
25
|
+
wrapToolCall(request: R, handler: (request: R) => Promise<T>): Promise<T | ToolMessageLike>;
|
|
26
|
+
}
|
|
27
|
+
export declare function gateMiddleware<R extends ToolCallRequest = ToolCallRequest, T = unknown>(options: {
|
|
28
|
+
gate: Gate;
|
|
29
|
+
tools: string[];
|
|
30
|
+
toInput: (request: R) => EvaluateInput;
|
|
31
|
+
commit?: boolean;
|
|
32
|
+
}): ToolCallMiddleware<R, T>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe } from "./ai-sdk.js";
|
|
2
|
+
export function gateMiddleware(options) {
|
|
3
|
+
const commit = options.commit ?? true;
|
|
4
|
+
const watched = new Set(options.tools);
|
|
5
|
+
return {
|
|
6
|
+
name: "proactive-gate",
|
|
7
|
+
async wrapToolCall(request, handler) {
|
|
8
|
+
if (!watched.has(request.toolCall.name))
|
|
9
|
+
return handler(request);
|
|
10
|
+
const input = options.toInput(request);
|
|
11
|
+
const decision = await options.gate.evaluate(input);
|
|
12
|
+
const refuse = (reason) => ({ type: "tool", content: `proactive-gate: ${reason}`, tool_call_id: request.toolCall.id ?? "", status: "error" });
|
|
13
|
+
if (!decision.allowed)
|
|
14
|
+
return refuse(describe(decision));
|
|
15
|
+
if (commit && !(await options.gate.commit(decision, input)))
|
|
16
|
+
return refuse("a budget was exhausted at commit");
|
|
17
|
+
return handler(request);
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Mastra output processor. Put it in the agent's `outputProcessors`; when
|
|
3
|
+
* the gate rejects, the processor calls abort(reason) and the agent's result
|
|
4
|
+
* is stopped before it reaches the user.
|
|
5
|
+
*
|
|
6
|
+
* outputProcessors: [gateProcessor({ gate, toInput: ({ messages }) => ({ user, candidate: { id, type: "reply" } }) })]
|
|
7
|
+
*/
|
|
8
|
+
import type { Gate } from "../gate.js";
|
|
9
|
+
import type { EvaluateInput } from "../types.js";
|
|
10
|
+
export interface ProcessorArgs<M = unknown> {
|
|
11
|
+
messages: M[];
|
|
12
|
+
abort: (reason?: string) => never;
|
|
13
|
+
}
|
|
14
|
+
export interface OutputProcessor<M = unknown> {
|
|
15
|
+
id: string;
|
|
16
|
+
processOutputResult(args: ProcessorArgs<M>): Promise<M[]>;
|
|
17
|
+
}
|
|
18
|
+
export declare function gateProcessor<M = unknown>(options: {
|
|
19
|
+
gate: Gate;
|
|
20
|
+
toInput: (args: {
|
|
21
|
+
messages: M[];
|
|
22
|
+
}) => EvaluateInput;
|
|
23
|
+
id?: string;
|
|
24
|
+
commit?: boolean;
|
|
25
|
+
}): OutputProcessor<M>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe } from "./ai-sdk.js";
|
|
2
|
+
export function gateProcessor(options) {
|
|
3
|
+
const commit = options.commit ?? true;
|
|
4
|
+
return {
|
|
5
|
+
id: options.id ?? "proactive-gate",
|
|
6
|
+
async processOutputResult({ messages, abort }) {
|
|
7
|
+
const input = options.toInput({ messages });
|
|
8
|
+
const decision = await options.gate.evaluate(input);
|
|
9
|
+
if (!decision.allowed)
|
|
10
|
+
return abort(`proactive-gate: ${describe(decision)}`);
|
|
11
|
+
if (commit && !(await options.gate.commit(decision, input)))
|
|
12
|
+
return abort("proactive-gate: a budget was exhausted at commit");
|
|
13
|
+
return messages;
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An OpenAI Agents SDK tool input guardrail. Attach it to the send tool with
|
|
3
|
+
* `defineToolInputGuardrail`-compatible shape; a rejection trips the wire with
|
|
4
|
+
* the reason in outputInfo.
|
|
5
|
+
*
|
|
6
|
+
* tool({ name: "send_message", inputGuardrails: [gateToolInputGuardrail({ gate, toInput: ({ input }) => input.gate })] })
|
|
7
|
+
*/
|
|
8
|
+
import type { Gate } from "../gate.js";
|
|
9
|
+
import type { EvaluateInput } from "../types.js";
|
|
10
|
+
export interface GuardrailArgs<I = unknown> {
|
|
11
|
+
input: I;
|
|
12
|
+
context?: unknown;
|
|
13
|
+
}
|
|
14
|
+
export interface GuardrailResult {
|
|
15
|
+
tripwireTriggered: boolean;
|
|
16
|
+
outputInfo: {
|
|
17
|
+
reason?: string;
|
|
18
|
+
surfaces?: string[];
|
|
19
|
+
deliverAt?: string;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export interface ToolInputGuardrail<I = unknown> {
|
|
23
|
+
name: string;
|
|
24
|
+
execute(args: GuardrailArgs<I>): Promise<GuardrailResult>;
|
|
25
|
+
}
|
|
26
|
+
export declare function gateToolInputGuardrail<I = unknown>(options: {
|
|
27
|
+
gate: Gate;
|
|
28
|
+
toInput: (args: GuardrailArgs<I>) => EvaluateInput;
|
|
29
|
+
name?: string;
|
|
30
|
+
commit?: boolean;
|
|
31
|
+
}): ToolInputGuardrail<I>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe } from "./ai-sdk.js";
|
|
2
|
+
export function gateToolInputGuardrail(options) {
|
|
3
|
+
const commit = options.commit ?? true;
|
|
4
|
+
return {
|
|
5
|
+
name: options.name ?? "proactive-gate",
|
|
6
|
+
async execute(args) {
|
|
7
|
+
const input = options.toInput(args);
|
|
8
|
+
const decision = await options.gate.evaluate(input);
|
|
9
|
+
if (!decision.allowed)
|
|
10
|
+
return { tripwireTriggered: true, outputInfo: { reason: describe(decision) } };
|
|
11
|
+
if (commit && !(await options.gate.commit(decision, input)))
|
|
12
|
+
return { tripwireTriggered: true, outputInfo: { reason: "a budget was exhausted at commit" } };
|
|
13
|
+
return { tripwireTriggered: false, outputInfo: { surfaces: decision.surfaces, ...(decision.deliverAt ? { deliverAt: decision.deliverAt.toISOString() } : {}) } };
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|