@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 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
  [![npm](https://img.shields.io/npm/v/@pliuz/sdk)](https://www.npmjs.com/package/@pliuz/sdk)
4
4
  [![Node](https://img.shields.io/node/v/@pliuz/sdk)](https://www.npmjs.com/package/@pliuz/sdk)
5
- [![License](https://img.shields.io/npm/l/@pliuz/sdk)](https://github.com/mwhitex/pliuz/blob/main/sdk-typescript/LICENSE)
5
+ [![License](https://img.shields.io/npm/l/@pliuz/sdk)](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.dev](https://pliuz.dev), create an agent in the dashboard, copy the key.
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
- - **Cancellable** via `AbortSignal` (pass `timeoutMs`)
104
- - **Single-shot execution reporting** — closes the audit loop automatically
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-dev.vercel.app` | Override for self-hosted or staging. |
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 same args same key. The backend dedupes within 24 h, so safe to retry from anywhere (queues, retry loops, agent restarts).
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
- ```typescript
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
- // Calling twice in a 24h window → only 1 approval request created.
274
- // Same human decision applies to both. No duplicate refunds.
275
- await refund('cus_123', 5000)
276
- await refund('cus_123', 5000)
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 idempotency hash algorithm is identical to the [Python SDK](https://pypi.org/project/pliuz/). A call from Python + a call from TypeScript with the same `tool_name`, `tool_args`, and `sessionId` produces the same key.
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 SDK uses only `fetch` + `AbortController` + `crypto.createHash`. Should work in any modern runtime that exposes these (Bun, Deno via `npm:` specifier, Vercel Edge, Cloudflare Workers). Not tested in CI file an issue if you hit anything.
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.dev/docs
324
- - **GitHub**: https://github.com/mwhitex/pliuz
325
- - **Issues**: https://github.com/mwhitex/pliuz/issues
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
  ---
@@ -5,7 +5,7 @@ var crypto = require('crypto');
5
5
  // src/gated.ts
6
6
 
7
7
  // src/version.ts
8
- var VERSION = "0.1.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-dev.vercel.app";
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: approvalUrl(this.baseUrl, approvalId),
354
+ url,
322
355
  apiKey: this.apiKey,
323
- timeoutMs: this.timeoutMs,
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
- } else {
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, sendArgs, options.sessionId);
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
- if (Date.now() >= deadline) {
502
+ const remainingMs = deadline - Date.now();
503
+ if (remainingMs <= 0) {
463
504
  throw new PliuzApprovalTimeoutError(approvalId, timeoutMs);
464
505
  }
465
- await sleep2(pollIntervalMs);
466
- const approval = await activeClient.getApproval(approvalId);
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(...args);
522
+ const result = await fn(...callArgs);
473
523
  const latencyMs = Date.now() - started;
474
- void safeReport(activeClient, approvalId, "success", null, latencyMs, result);
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
- void safeReport(activeClient, approvalId, "error", errMsg.slice(0, 2e3), latencyMs, null);
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(result).slice(0, 2e3);
592
+ excerpt = JSON.stringify(toDump).slice(0, 2e3);
518
593
  } catch {
519
- excerpt = String(result).slice(0, 2e3);
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 ?? (typeof tool.description === "string" ? tool.description.slice(0, 64) : "ai_tool");
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
- pendingOptions = opts;
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 {