@pliuz/sdk 0.1.0 → 0.2.2
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/CHANGELOG.md +75 -0
- package/README.md +51 -22
- package/dist/adapters/ai.cjs +125 -47
- package/dist/adapters/ai.cjs.map +1 -1
- package/dist/adapters/ai.d.cts +8 -5
- package/dist/adapters/ai.d.ts +8 -5
- package/dist/adapters/ai.js +125 -47
- package/dist/adapters/ai.js.map +1 -1
- package/dist/{client-BABvN_88.d.cts → client-K9c9HsAc.d.cts} +9 -3
- package/dist/{client-BABvN_88.d.ts → client-K9c9HsAc.d.ts} +9 -3
- package/dist/index.cjs +104 -27
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +47 -5
- package/dist/index.d.ts +47 -5
- package/dist/index.js +103 -28
- package/dist/index.js.map +1 -1
- package/package.json +4 -5
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,81 @@ All notable changes to `@pliuz/sdk` (TypeScript SDK) are documented in this file
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.2.2] — 2026-06-13
|
|
9
|
+
|
|
10
|
+
Ships the F2 long-poll that landed in the repo *after* the 0.2.1 tag, plus Vercel AI
|
|
11
|
+
adapter and redaction hardening from the architecture-closure pass.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **Long-poll decision delivery (F2).** `gated()` now waits on
|
|
16
|
+
`GET /approvals/:id?wait=` (up to 25s server-side), cutting polling volume ~12x. The
|
|
17
|
+
code existed in the repo but was committed after the 0.2.1 tag, so
|
|
18
|
+
`npm install @pliuz/sdk` did not include it until now.
|
|
19
|
+
- **Strict redaction mode (D13).** `gated({ ..., redactStrict: true })` and
|
|
20
|
+
`applyRedaction(payload, paths, true)` throw `PliuzRedactionPathError` when a path
|
|
21
|
+
matches no field, instead of silently leaving it unredacted. The default stays lenient.
|
|
22
|
+
|
|
23
|
+
### Fixed / Changed (behavior changes)
|
|
24
|
+
|
|
25
|
+
- **Vercel AI adapter `toolName` is now required (D14).** `gatedTool` no longer derives
|
|
26
|
+
the Pliuz tool name from the tool's prose `description` — a copy edit silently
|
|
27
|
+
re-routed approvals. Pass `toolName` explicitly; a missing/blank value throws.
|
|
28
|
+
- **Vercel AI adapter concurrency fix (D14).** Execution `options` are now captured
|
|
29
|
+
per-call instead of via a shared mutable holder that cross-talked between concurrent
|
|
30
|
+
tool invocations.
|
|
31
|
+
|
|
32
|
+
## [0.2.1] — 2026-06-12
|
|
33
|
+
|
|
34
|
+
Identical in content to 0.2.0 (below), which never reached npm — its release
|
|
35
|
+
CI run failed on a monorepo PostCSS config leak into the SDK's vitest, fixed
|
|
36
|
+
here. First published version of the 0.2 line.
|
|
37
|
+
|
|
38
|
+
## [0.2.0] — 2026-06-12 (not published to npm)
|
|
39
|
+
|
|
40
|
+
Correctness release from an internal security/architecture review. Three of
|
|
41
|
+
these are behavior changes you should read before upgrading.
|
|
42
|
+
|
|
43
|
+
### Fixed (behavior changes)
|
|
44
|
+
|
|
45
|
+
- **Human edits are now executed.** When an approver decides with
|
|
46
|
+
*Edit & Approve*, the wrapper now runs the edited `final_args` — previously
|
|
47
|
+
it silently ran the ORIGINAL args the agent sent. With the default
|
|
48
|
+
`toolArgs` mapper this is automatic; custom mappers need the new
|
|
49
|
+
`applyFinalArgs` option or the call throws the new
|
|
50
|
+
`PliuzEditNotApplicableError` (the SDK refuses to execute args a human did
|
|
51
|
+
not approve). Redaction placeholders echoed back by the approver are
|
|
52
|
+
restored to the original values.
|
|
53
|
+
- **Idempotency keys are now hashed over PRE-redaction args.** Previously two
|
|
54
|
+
calls differing only in a redacted field (e.g. two transfers to different
|
|
55
|
+
IBANs) produced the SAME key, so the second silently replayed the first's
|
|
56
|
+
approval. Keys change across this upgrade: in-flight dedupe windows reset
|
|
57
|
+
(worst case: one extra approval request, never a skipped approval).
|
|
58
|
+
- **Execution reports are awaited** before the wrapped call returns.
|
|
59
|
+
Previously they were fire-and-forget, which on serverless (Vercel/Lambda)
|
|
60
|
+
routinely froze before the report was sent — silently losing the audit
|
|
61
|
+
loop's execution record.
|
|
62
|
+
- **The execution excerpt is redacted** with the same `redact` paths as
|
|
63
|
+
`tool_args`. Previously a tool that returned the sensitive field it
|
|
64
|
+
received re-leaked it through `target_response_excerpt`.
|
|
65
|
+
|
|
66
|
+
### Changed
|
|
67
|
+
|
|
68
|
+
- Default `baseUrl` is now `https://pliuz.com` (was a dev deployment URL).
|
|
69
|
+
- README idempotency section rewritten to match real backend semantics
|
|
70
|
+
(24h dedupe window, per-agent, payload-conflict → 409) and to recommend a
|
|
71
|
+
per-run `sessionId` for legitimately repeating calls.
|
|
72
|
+
- README no longer claims AbortSignal cancellation or Vercel Edge support
|
|
73
|
+
(neither shipped yet — both on the roadmap).
|
|
74
|
+
- Contract tests now FAIL (instead of silently skipping) when the OpenAPI
|
|
75
|
+
spec is missing, and resolve a vendored `api-spec/openapi.yaml` in the
|
|
76
|
+
public mirror.
|
|
77
|
+
|
|
78
|
+
### Added
|
|
79
|
+
|
|
80
|
+
- `applyFinalArgs?: (finalArgs, originalArgs) => TArgs` in `GatedOptions`.
|
|
81
|
+
- `PliuzEditNotApplicableError` (exported).
|
|
82
|
+
|
|
8
83
|
## [0.1.0] — 2026-05-21
|
|
9
84
|
|
|
10
85
|
### Added
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@pliuz/sdk)
|
|
4
4
|
[](https://www.npmjs.com/package/@pliuz/sdk)
|
|
5
|
-
[](https://github.com/
|
|
5
|
+
[](https://github.com/pliuz/pliuz-ts/blob/main/sdk-typescript/LICENSE)
|
|
6
6
|
|
|
7
7
|
> **Human-in-the-loop approval gates for AI agents.** Wrap any function in `gated()` to pause execution, route the call to a human approver via Pliuz, then resume — or abort — based on the decision. Every call is audited.
|
|
8
8
|
|
|
@@ -44,7 +44,7 @@ Requires Node ≥ 18.17 (uses native `fetch` + `AbortController`).
|
|
|
44
44
|
|
|
45
45
|
### 1. Get an API key
|
|
46
46
|
|
|
47
|
-
Sign up at [pliuz.
|
|
47
|
+
Sign up at [pliuz.com](https://pliuz.com/signup), create an agent in the dashboard, copy the key.
|
|
48
48
|
|
|
49
49
|
### 2. Set the env var
|
|
50
50
|
|
|
@@ -99,9 +99,10 @@ yourFn(...args) yourFn(...args)
|
|
|
99
99
|
- **Dual ESM/CJS** — works in any modern Node project
|
|
100
100
|
- **First-class TypeScript** — fully typed, including narrow error subclasses
|
|
101
101
|
- **Idempotency** via deterministic key hashing — safe to retry from anywhere
|
|
102
|
-
- **Client-side redaction** — sensitive fields never leave your process plaintext
|
|
103
|
-
- **
|
|
104
|
-
- **
|
|
102
|
+
- **Client-side redaction** — sensitive fields never leave your process plaintext (applied to tool args AND the execution excerpt)
|
|
103
|
+
- **Bounded waiting** via `timeoutMs` (first-class `AbortSignal` cancellation is on the roadmap — not shipped yet)
|
|
104
|
+
- **Human edits honored** — if an approver edits the args, the edited args execute (or a typed error is thrown), never the original ones
|
|
105
|
+
- **Single-shot execution reporting** — awaited before your call returns, closing the audit loop reliably on serverless
|
|
105
106
|
- **Typed errors** — `PliuzRejectedError`, `PliuzApprovalExpiredError`, `PliuzPolicyError`, etc.
|
|
106
107
|
- **Vercel AI SDK adapter** — `gatedTool()` wraps `tool({...})` from `ai` so existing AI SDK agents gate transparently
|
|
107
108
|
|
|
@@ -221,7 +222,7 @@ import { z } from 'zod'
|
|
|
221
222
|
import { gatedTool } from '@pliuz/sdk/adapters/ai'
|
|
222
223
|
|
|
223
224
|
const issueRefund = gatedTool(
|
|
224
|
-
{ policy: 'refund', redact: ['customer.ssn'] },
|
|
225
|
+
{ toolName: 'issue_refund', policy: 'refund', redact: ['customer.ssn'] },
|
|
225
226
|
tool({
|
|
226
227
|
description: 'Issue a refund to a customer',
|
|
227
228
|
parameters: z.object({
|
|
@@ -249,7 +250,7 @@ The wrapped tool preserves `description` and `parameters` — the LLM sees it id
|
|
|
249
250
|
| Env var | Default | Purpose |
|
|
250
251
|
|---|---|---|
|
|
251
252
|
| `PLIUZ_API_KEY` | _(required)_ | Per-agent API key. `pli_live_...` format. |
|
|
252
|
-
| `PLIUZ_BASE_URL` | `https://pliuz
|
|
253
|
+
| `PLIUZ_BASE_URL` | `https://pliuz.com` | Override for self-hosted or staging. |
|
|
253
254
|
|
|
254
255
|
All env vars can be passed as `PliuzClient` constructor options:
|
|
255
256
|
|
|
@@ -261,24 +262,52 @@ new PliuzClient({ apiKey: 'pli_live_...', baseUrl: 'https://pliuz.mycompany.com'
|
|
|
261
262
|
|
|
262
263
|
## Production tips
|
|
263
264
|
|
|
264
|
-
### Idempotency
|
|
265
|
+
### Idempotency — read this before relying on it
|
|
265
266
|
|
|
266
|
-
`gated()` automatically generates a deterministic `idempotency_key` per call
|
|
267
|
+
`gated()` automatically generates a deterministic `idempotency_key` per call from `(tool_name, args, sessionId)`. The key is hashed over the **pre-redaction** args (only the truncated SHA-256 leaves your process), so two calls that differ only in a redacted field get **different** keys — each needs its own approval.
|
|
267
268
|
|
|
268
|
-
|
|
269
|
-
const refund = gated({ policy: 'refund' }, async (id: string, cents: number) => {
|
|
270
|
-
// ...
|
|
271
|
-
})
|
|
269
|
+
**Important:** the backend currently dedupes on this key with **no time window** — a `(tool_name, args, sessionId)` combination that was approved once is replayed as approved on every identical future call (and a rejected one stays rejected). The dedupe exists for HTTP-retry safety, but with the default `sessionId: undefined` it also applies across runs and days. **If your agent legitimately repeats identical calls (crons, recurring jobs), set a per-run `sessionId`** so each run gets its own approval:
|
|
272
270
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
271
|
+
```typescript
|
|
272
|
+
const refund = gated(
|
|
273
|
+
{ policy: 'refund', sessionId: runId }, // scope approvals to this run
|
|
274
|
+
async (id: string, cents: number) => { /* ... */ },
|
|
275
|
+
)
|
|
277
276
|
```
|
|
278
277
|
|
|
278
|
+
Within one run, retries are then safe: same args → one approval request, one human decision.
|
|
279
|
+
|
|
280
|
+
> A bounded server-side dedupe window is planned; until then treat the replay semantics above as the contract.
|
|
281
|
+
|
|
279
282
|
### Cross-language idempotency
|
|
280
283
|
|
|
281
|
-
The
|
|
284
|
+
The hash algorithm matches the [Python SDK](https://pypi.org/project/pliuz/) for ASCII string/integer payloads. Known divergence: non-ASCII strings and float-typed numbers serialize differently between `JSON.stringify` and Python's `json.dumps`, producing different keys — don't rely on cross-language dedupe for those payloads yet.
|
|
285
|
+
|
|
286
|
+
### When the approver edits the args
|
|
287
|
+
|
|
288
|
+
If a human decides with **Edit & Approve**, `gated()` executes the **edited** `final_args`, never the original call. With the default `toolArgs` mapper this is automatic. With a custom `toolArgs` mapper, provide `applyFinalArgs` to map the edited object back onto your function's arguments — otherwise the SDK throws `PliuzEditNotApplicableError` (it refuses to run args the human did not approve):
|
|
289
|
+
|
|
290
|
+
```typescript
|
|
291
|
+
const refund = gated(
|
|
292
|
+
{
|
|
293
|
+
toolArgs: (id: string, cents: number) => ({ customer_id: id, amount_cents: cents }),
|
|
294
|
+
applyFinalArgs: (fa, [id]) => [id, fa.amount_cents as number] as const,
|
|
295
|
+
},
|
|
296
|
+
async (id: string, cents: number) => { /* ... */ },
|
|
297
|
+
)
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### Decision latency (long-poll)
|
|
301
|
+
|
|
302
|
+
`gated()` waits for the human decision via **server long-poll**: each wait call
|
|
303
|
+
holds the connection open up to ~25s and returns the instant the approval is
|
|
304
|
+
decided. You get near-zero decision-delivery latency and ~12x fewer requests
|
|
305
|
+
than fixed-interval polling — no configuration needed. `pollIntervalMs` now
|
|
306
|
+
just caps the per-call wait window. The low-level client exposes it directly:
|
|
307
|
+
|
|
308
|
+
```typescript
|
|
309
|
+
const a = await pliuz.getApproval(id, 25) // long-poll up to 25s
|
|
310
|
+
```
|
|
282
311
|
|
|
283
312
|
### Custom timeouts per call
|
|
284
313
|
|
|
@@ -292,7 +321,7 @@ If polling exceeds `timeoutMs`, you get `PliuzApprovalTimeoutError`. **Your wrap
|
|
|
292
321
|
|
|
293
322
|
### Edge / Bun / Deno
|
|
294
323
|
|
|
295
|
-
The
|
|
324
|
+
The HTTP layer uses native `fetch` + `AbortController`, but `gated()` imports `node:crypto` at module load — so today the SDK requires a Node-compatible runtime: **Node ≥ 18.17, Bun, Deno (via `npm:`), and Cloudflare Workers with `nodejs_compat`**. **Vercel Edge runtime is NOT supported yet** (no `node:crypto` there); use the Node.js runtime for routes that import this SDK. A Web-Crypto build is on the roadmap.
|
|
296
325
|
|
|
297
326
|
---
|
|
298
327
|
|
|
@@ -320,9 +349,9 @@ See [`examples/`](./examples) for runnable scripts:
|
|
|
320
349
|
|
|
321
350
|
## Links
|
|
322
351
|
|
|
323
|
-
- **Docs**: https://pliuz.
|
|
324
|
-
- **GitHub**: https://github.com/
|
|
325
|
-
- **Issues**: https://github.com/
|
|
352
|
+
- **Docs**: https://pliuz.com/docs
|
|
353
|
+
- **GitHub**: https://github.com/pliuz/pliuz-ts
|
|
354
|
+
- **Issues**: https://github.com/pliuz/pliuz-ts/issues
|
|
326
355
|
- **Python SDK**: [`pliuz`](https://pypi.org/project/pliuz/)
|
|
327
356
|
|
|
328
357
|
---
|
package/dist/adapters/ai.cjs
CHANGED
|
@@ -5,7 +5,7 @@ var crypto = require('crypto');
|
|
|
5
5
|
// src/gated.ts
|
|
6
6
|
|
|
7
7
|
// src/version.ts
|
|
8
|
-
var VERSION = "0.
|
|
8
|
+
var VERSION = "0.2.2";
|
|
9
9
|
|
|
10
10
|
// src/errors.ts
|
|
11
11
|
var PliuzError = class extends Error {
|
|
@@ -24,6 +24,17 @@ var PliuzNetworkError = class extends PliuzError {
|
|
|
24
24
|
}
|
|
25
25
|
cause;
|
|
26
26
|
};
|
|
27
|
+
var PliuzRedactionPathError = class extends PliuzError {
|
|
28
|
+
constructor(path) {
|
|
29
|
+
super(
|
|
30
|
+
`redaction path '${path}' did not match any field in the payload (strict mode). Fix the path or set redactStrict=false to ignore misses.`
|
|
31
|
+
);
|
|
32
|
+
this.path = path;
|
|
33
|
+
this.name = "PliuzRedactionPathError";
|
|
34
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
35
|
+
}
|
|
36
|
+
path;
|
|
37
|
+
};
|
|
27
38
|
var PliuzTimeoutError = class extends PliuzError {
|
|
28
39
|
constructor(message) {
|
|
29
40
|
super(message);
|
|
@@ -134,6 +145,19 @@ var PliuzApprovalTimeoutError = class extends PliuzError {
|
|
|
134
145
|
approvalId;
|
|
135
146
|
timeoutMs;
|
|
136
147
|
};
|
|
148
|
+
var PliuzEditNotApplicableError = class extends PliuzError {
|
|
149
|
+
constructor(approvalId, finalArgs) {
|
|
150
|
+
super(
|
|
151
|
+
`approval ${approvalId} was approved with edited args, but they cannot be mapped back to the wrapped function (custom toolArgs mapper without applyFinalArgs, or incompatible shape) \u2014 refusing to execute the original (unapproved) args. Apply e.finalArgs manually.`
|
|
152
|
+
);
|
|
153
|
+
this.approvalId = approvalId;
|
|
154
|
+
this.finalArgs = finalArgs;
|
|
155
|
+
this.name = "PliuzEditNotApplicableError";
|
|
156
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
157
|
+
}
|
|
158
|
+
approvalId;
|
|
159
|
+
finalArgs;
|
|
160
|
+
};
|
|
137
161
|
var STATUS_TO_ERROR = {
|
|
138
162
|
400: PliuzValidationError,
|
|
139
163
|
401: PliuzAuthError,
|
|
@@ -240,7 +264,7 @@ async function request(opts) {
|
|
|
240
264
|
}
|
|
241
265
|
|
|
242
266
|
// src/client.ts
|
|
243
|
-
var DEFAULT_BASE_URL = "https://pliuz
|
|
267
|
+
var DEFAULT_BASE_URL = "https://pliuz.com";
|
|
244
268
|
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
245
269
|
var DEFAULT_MAX_RETRIES = 3;
|
|
246
270
|
var ENV_API_KEY = "PLIUZ_API_KEY";
|
|
@@ -265,6 +289,7 @@ function approvalUrl(baseUrl, approvalId, suffix) {
|
|
|
265
289
|
if (suffix) path += `/${suffix}`;
|
|
266
290
|
return baseUrl + path;
|
|
267
291
|
}
|
|
292
|
+
var MAX_WAIT_SECONDS = 25;
|
|
268
293
|
var PliuzClient = class {
|
|
269
294
|
apiKey;
|
|
270
295
|
baseUrl;
|
|
@@ -314,13 +339,22 @@ var PliuzClient = class {
|
|
|
314
339
|
/**
|
|
315
340
|
* Fetch the current state of an approval request.
|
|
316
341
|
* Used by polling in `gated()` to detect status transitions.
|
|
342
|
+
*
|
|
343
|
+
* Pass `waitSeconds` (1..25) to long-poll: the server holds the request
|
|
344
|
+
* open and returns the instant the approval stops being `pending`, or after
|
|
345
|
+
* `waitSeconds` (still pending). Cuts polling volume and decision latency
|
|
346
|
+
* ~12x vs fixed-interval polling. The per-request HTTP timeout is bumped to
|
|
347
|
+
* cover the wait window.
|
|
317
348
|
*/
|
|
318
|
-
async getApproval(approvalId) {
|
|
349
|
+
async getApproval(approvalId, waitSeconds) {
|
|
350
|
+
const wait = waitSeconds && waitSeconds > 0 ? Math.min(Math.floor(waitSeconds), MAX_WAIT_SECONDS) : 0;
|
|
351
|
+
const url = wait > 0 ? `${approvalUrl(this.baseUrl, approvalId)}?wait=${wait}` : approvalUrl(this.baseUrl, approvalId);
|
|
319
352
|
return request({
|
|
320
353
|
method: "GET",
|
|
321
|
-
url
|
|
354
|
+
url,
|
|
322
355
|
apiKey: this.apiKey,
|
|
323
|
-
|
|
356
|
+
// Give the socket head-room over the server-side wait window.
|
|
357
|
+
timeoutMs: wait > 0 ? Math.max(this.timeoutMs, wait * 1e3 + 5e3) : this.timeoutMs,
|
|
324
358
|
maxRetries: this.maxRetries,
|
|
325
359
|
fetchImpl: this.fetchImpl
|
|
326
360
|
});
|
|
@@ -353,13 +387,16 @@ var PliuzClient = class {
|
|
|
353
387
|
|
|
354
388
|
// src/redaction.ts
|
|
355
389
|
var REDACTED_PLACEHOLDER = "<redacted>";
|
|
356
|
-
function applyRedaction(payload, paths) {
|
|
390
|
+
function applyRedaction(payload, paths, strict = false) {
|
|
357
391
|
if (!paths || paths.length === 0) {
|
|
358
392
|
return deepClone(payload);
|
|
359
393
|
}
|
|
360
394
|
const result = deepClone(payload);
|
|
361
395
|
for (const path of paths) {
|
|
362
|
-
redactPath(result, parsePath(path));
|
|
396
|
+
const matched = redactPath(result, parsePath(path));
|
|
397
|
+
if (strict && !matched) {
|
|
398
|
+
throw new PliuzRedactionPathError(path);
|
|
399
|
+
}
|
|
363
400
|
}
|
|
364
401
|
return result;
|
|
365
402
|
}
|
|
@@ -387,28 +424,30 @@ function parsePath(path) {
|
|
|
387
424
|
return segments;
|
|
388
425
|
}
|
|
389
426
|
function redactPath(node, segments) {
|
|
390
|
-
if (segments.length === 0) return;
|
|
391
|
-
if (!isPlainObject(node)) return;
|
|
427
|
+
if (segments.length === 0) return true;
|
|
428
|
+
if (!isPlainObject(node)) return false;
|
|
392
429
|
const [head, ...rest] = segments;
|
|
393
|
-
if (!head) return;
|
|
430
|
+
if (!head) return false;
|
|
394
431
|
const { key, isArrayWildcard } = head;
|
|
395
|
-
if (!(key in node)) return;
|
|
432
|
+
if (!(key in node)) return false;
|
|
396
433
|
if (isArrayWildcard) {
|
|
397
434
|
const target = node[key];
|
|
398
|
-
if (!Array.isArray(target)) return;
|
|
435
|
+
if (!Array.isArray(target)) return false;
|
|
399
436
|
if (rest.length === 0) {
|
|
400
437
|
node[key] = target.map(() => REDACTED_PLACEHOLDER);
|
|
401
|
-
return;
|
|
438
|
+
return true;
|
|
402
439
|
}
|
|
440
|
+
let matched = target.length > 0;
|
|
403
441
|
for (const item of target) {
|
|
404
|
-
redactPath(item, rest);
|
|
442
|
+
if (!redactPath(item, rest)) matched = false;
|
|
405
443
|
}
|
|
444
|
+
return matched;
|
|
406
445
|
} else {
|
|
407
446
|
if (rest.length === 0) {
|
|
408
447
|
node[key] = REDACTED_PLACEHOLDER;
|
|
409
|
-
|
|
410
|
-
redactPath(node[key], rest);
|
|
448
|
+
return true;
|
|
411
449
|
}
|
|
450
|
+
return redactPath(node[key], rest);
|
|
412
451
|
}
|
|
413
452
|
}
|
|
414
453
|
function isPlainObject(v) {
|
|
@@ -436,8 +475,8 @@ function gated(options, fn) {
|
|
|
436
475
|
return async (...args) => {
|
|
437
476
|
const activeClient = options.client ?? new PliuzClient();
|
|
438
477
|
const rawArgs = toolArgsMapper(...args);
|
|
439
|
-
const sendArgs = redactPaths && redactPaths.length > 0 ? applyRedaction(rawArgs, redactPaths) : rawArgs;
|
|
440
|
-
const idempotencyKey = idempotencyKeyFor(toolName,
|
|
478
|
+
const sendArgs = redactPaths && redactPaths.length > 0 ? applyRedaction(rawArgs, redactPaths, options.redactStrict ?? false) : rawArgs;
|
|
479
|
+
const idempotencyKey = idempotencyKeyFor(toolName, rawArgs, options.sessionId);
|
|
441
480
|
const metadata = {};
|
|
442
481
|
if (options.policy) metadata.policy = options.policy;
|
|
443
482
|
const response = await activeClient.createApproval({
|
|
@@ -456,27 +495,38 @@ function gated(options, fn) {
|
|
|
456
495
|
if (TERMINAL_EXPIRED.has(response.status)) {
|
|
457
496
|
throw new PliuzApprovalExpiredError(approvalId);
|
|
458
497
|
}
|
|
498
|
+
let approval = null;
|
|
459
499
|
if (!TERMINAL_OK.has(response.status)) {
|
|
460
500
|
const deadline = Date.now() + timeoutMs;
|
|
461
501
|
while (true) {
|
|
462
|
-
|
|
502
|
+
const remainingMs = deadline - Date.now();
|
|
503
|
+
if (remainingMs <= 0) {
|
|
463
504
|
throw new PliuzApprovalTimeoutError(approvalId, timeoutMs);
|
|
464
505
|
}
|
|
465
|
-
|
|
466
|
-
const
|
|
506
|
+
const waitSeconds = Math.max(1, Math.ceil(Math.min(remainingMs, pollIntervalMs * 1e3) / 1e3));
|
|
507
|
+
const callStart = Date.now();
|
|
508
|
+
approval = await activeClient.getApproval(approvalId, waitSeconds);
|
|
467
509
|
if (terminalOrThrow(approval)) break;
|
|
510
|
+
if (Date.now() - callStart < 250) await sleep2(pollIntervalMs);
|
|
468
511
|
}
|
|
512
|
+
} else if (response.is_replay) {
|
|
513
|
+
approval = await activeClient.getApproval(approvalId);
|
|
514
|
+
}
|
|
515
|
+
let callArgs = args;
|
|
516
|
+
const finalArgs = approval?.final_args ?? null;
|
|
517
|
+
if (finalArgs) {
|
|
518
|
+
callArgs = resolveEditedArgs(approvalId, rawArgs, finalArgs, args, options);
|
|
469
519
|
}
|
|
470
520
|
const started = Date.now();
|
|
471
521
|
try {
|
|
472
|
-
const result = await fn(...
|
|
522
|
+
const result = await fn(...callArgs);
|
|
473
523
|
const latencyMs = Date.now() - started;
|
|
474
|
-
|
|
524
|
+
await safeReport(activeClient, approvalId, "success", null, latencyMs, result, redactPaths);
|
|
475
525
|
return result;
|
|
476
526
|
} catch (e) {
|
|
477
527
|
const latencyMs = Date.now() - started;
|
|
478
528
|
const errMsg = e instanceof Error ? e.message : String(e);
|
|
479
|
-
|
|
529
|
+
await safeReport(activeClient, approvalId, "error", errMsg.slice(0, 2e3), latencyMs, null, redactPaths);
|
|
480
530
|
throw e;
|
|
481
531
|
}
|
|
482
532
|
};
|
|
@@ -500,6 +550,30 @@ function canonicalJSON(value) {
|
|
|
500
550
|
const keys = Object.keys(obj).sort();
|
|
501
551
|
return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalJSON(obj[k])).join(",") + "}";
|
|
502
552
|
}
|
|
553
|
+
function mergeFinalArgs(raw, final) {
|
|
554
|
+
if (isPlainRecord(raw) && isPlainRecord(final)) {
|
|
555
|
+
const out = {};
|
|
556
|
+
for (const [k, v] of Object.entries(final)) {
|
|
557
|
+
out[k] = k in raw ? mergeFinalArgs(raw[k], v) : v;
|
|
558
|
+
}
|
|
559
|
+
return out;
|
|
560
|
+
}
|
|
561
|
+
if (final === REDACTED_PLACEHOLDER) return raw;
|
|
562
|
+
return final;
|
|
563
|
+
}
|
|
564
|
+
function isPlainRecord(v) {
|
|
565
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
566
|
+
}
|
|
567
|
+
function resolveEditedArgs(approvalId, rawArgs, finalArgs, originalArgs, options) {
|
|
568
|
+
const merged = mergeFinalArgs(rawArgs, finalArgs);
|
|
569
|
+
if (options.applyFinalArgs) {
|
|
570
|
+
return options.applyFinalArgs(merged, originalArgs);
|
|
571
|
+
}
|
|
572
|
+
if (options.toolArgs === void 0 && Array.isArray(merged.args)) {
|
|
573
|
+
return merged.args;
|
|
574
|
+
}
|
|
575
|
+
throw new PliuzEditNotApplicableError(approvalId, merged);
|
|
576
|
+
}
|
|
503
577
|
function terminalOrThrow(approval) {
|
|
504
578
|
if (TERMINAL_OK.has(approval.status)) return true;
|
|
505
579
|
if (TERMINAL_REJECT.has(approval.status)) {
|
|
@@ -510,13 +584,14 @@ function terminalOrThrow(approval) {
|
|
|
510
584
|
}
|
|
511
585
|
return false;
|
|
512
586
|
}
|
|
513
|
-
async function safeReport(client, approvalId, status, errorMessage, latencyMs, result) {
|
|
587
|
+
async function safeReport(client, approvalId, status, errorMessage, latencyMs, result, redactPaths) {
|
|
514
588
|
let excerpt = null;
|
|
515
589
|
if (result != null) {
|
|
590
|
+
const toDump = redactPaths && redactPaths.length > 0 && isPlainRecord(result) ? applyRedaction(result, redactPaths) : result;
|
|
516
591
|
try {
|
|
517
|
-
excerpt = JSON.stringify(
|
|
592
|
+
excerpt = JSON.stringify(toDump).slice(0, 2e3);
|
|
518
593
|
} catch {
|
|
519
|
-
excerpt = String(
|
|
594
|
+
excerpt = String(toDump).slice(0, 2e3);
|
|
520
595
|
}
|
|
521
596
|
}
|
|
522
597
|
try {
|
|
@@ -539,28 +614,31 @@ function gatedTool(options, tool) {
|
|
|
539
614
|
if (typeof tool.execute !== "function") {
|
|
540
615
|
return tool;
|
|
541
616
|
}
|
|
542
|
-
const toolName = options.toolName
|
|
617
|
+
const toolName = options.toolName;
|
|
618
|
+
if (typeof toolName !== "string" || toolName.trim() === "") {
|
|
619
|
+
throw new Error(
|
|
620
|
+
"gatedTool: `toolName` is required and must be a non-empty string. It drives Pliuz policy matching, so it must be explicit and stable \u2014 never derived from the tool description (a copy edit would silently re-route approvals)."
|
|
621
|
+
);
|
|
622
|
+
}
|
|
543
623
|
const originalExecute = tool.execute;
|
|
544
|
-
let pendingOptions = void 0;
|
|
545
|
-
const gatedExecute1Arg = gated(
|
|
546
|
-
{
|
|
547
|
-
policy: options.policy,
|
|
548
|
-
redact: options.redact,
|
|
549
|
-
timeoutMs: options.timeoutMs,
|
|
550
|
-
pollIntervalMs: options.pollIntervalMs,
|
|
551
|
-
client: options.client,
|
|
552
|
-
contextMessages: options.contextMessages,
|
|
553
|
-
sessionId: options.sessionId,
|
|
554
|
-
originator: options.originator,
|
|
555
|
-
toolName,
|
|
556
|
-
// Surface the Vercel-AI input dict directly as Pliuz tool_args
|
|
557
|
-
// instead of wrapping in `{ args: [input] }` for cleaner audit logs.
|
|
558
|
-
toolArgs: (input) => isPlainObject2(input) ? input : { input }
|
|
559
|
-
},
|
|
560
|
-
async (input) => originalExecute(input, pendingOptions)
|
|
561
|
-
);
|
|
562
624
|
const wrappedExecute = async (input, opts) => {
|
|
563
|
-
|
|
625
|
+
const gatedExecute1Arg = gated(
|
|
626
|
+
{
|
|
627
|
+
policy: options.policy,
|
|
628
|
+
redact: options.redact,
|
|
629
|
+
timeoutMs: options.timeoutMs,
|
|
630
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
631
|
+
client: options.client,
|
|
632
|
+
contextMessages: options.contextMessages,
|
|
633
|
+
sessionId: options.sessionId,
|
|
634
|
+
originator: options.originator,
|
|
635
|
+
toolName,
|
|
636
|
+
// Surface the Vercel-AI input dict directly as Pliuz tool_args
|
|
637
|
+
// instead of wrapping in `{ args: [input] }` for cleaner audit logs.
|
|
638
|
+
toolArgs: (inp) => isPlainObject2(inp) ? inp : { input: inp }
|
|
639
|
+
},
|
|
640
|
+
async (inp) => originalExecute(inp, opts)
|
|
641
|
+
);
|
|
564
642
|
return gatedExecute1Arg(input);
|
|
565
643
|
};
|
|
566
644
|
return {
|