@zanii/blackbox 0.6.0 → 0.8.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 CHANGED
@@ -83,6 +83,16 @@ verifyAnswerCredential(credential, lines, bodies, packs); // { ok, problems }: t
83
83
 
84
84
  The same checks run offline as pure functions: `toolHallucinations`, `groundingFindings`, `factsIn`, `loadReferencePacks`, `answerRisk`, `detectorAccuracy`.
85
85
 
86
+ ## New in 0.8.0
87
+
88
+ - `blackbox drain --close` (and `drainSpools({close: true})` / `drain_spools(close=True)`): after shipping what a stopped agent left behind, each session is closed as `abandoned`.
89
+ - Works with gateway v0.10: alerts signed as Standard Webhooks, tenant keys that expire, more identity-provider algorithms (PS256, ES384, EdDSA).
90
+
91
+ ## New in 0.7.0
92
+
93
+ - Any number of serverless functions or workers can write one session at the same moment: a joined session sends its events unnumbered, each with an id, and a v0.9+ gateway numbers them in arrival order (a resend adds nothing).
94
+ - The repeat-call detector counts within one turn: a chat assistant or support bot asked for the same lookup in separate messages isn't stopped as a loop. A real loop inside one task still is.
95
+
86
96
  ## New in 0.6.0
87
97
 
88
98
  - Serverless: a session joined with its id and token in a fresh function (Vercel, Lambda, Cloud Run) numbers its events from the server's `sdk_next`, so none are lost. Join in each invocation and flush before returning.
@@ -135,27 +135,78 @@ function changedSegment(before, after) {
135
135
  return b.length >= a.length ? "none" : a[b.length][0];
136
136
  }
137
137
  const f = (code, severity, ref, detail) => ({ code, source: "detectors", severity, ref, detail });
138
+ /** spec/findings.md §4: what the person has said in a model request (Anthropic, Chat Completions,
139
+ * Responses): how many messages, and the newest. Tool results aren't the person speaking. */
140
+ function humanTail(request) {
141
+ const raw = request?.messages ?? request?.input;
142
+ const list = Array.isArray(raw)
143
+ ? raw
144
+ : typeof raw === "string"
145
+ ? [{ role: "user", content: raw }]
146
+ : [];
147
+ let count = 0;
148
+ let last = "";
149
+ for (const m of list) {
150
+ if (!isObj(m) || m.role !== "user")
151
+ continue;
152
+ const c = m.content;
153
+ if (Array.isArray(c)) {
154
+ const blocks = c.filter(isObj);
155
+ if (blocks.some((b) => b.type === "tool_result"))
156
+ continue;
157
+ last = blocks
158
+ .map((b) => (typeof b.text === "string" ? b.text : ""))
159
+ .filter(Boolean)
160
+ .join("\n");
161
+ }
162
+ else
163
+ last = typeof c === "string" ? c : "";
164
+ count++;
165
+ }
166
+ return `${count}\n${last}`;
167
+ }
168
+ /** §4: each tool call's turn. A turn starts when the person says something new (a new message,
169
+ * or different newest words); a client resending the same request is still the same turn. */
170
+ function turnsOf(calls, tools) {
171
+ const byEnd = new Map();
172
+ let turn = -1;
173
+ let previous = null;
174
+ for (const c of calls) {
175
+ const tail = humanTail(c.request);
176
+ if (tail !== previous)
177
+ turn++;
178
+ previous = tail;
179
+ byEnd.set(c.endSeq, Math.max(turn, 0));
180
+ }
181
+ return tools.map((t) => byEnd.get(t.seq) ?? 0);
182
+ }
138
183
  export function detectors(lines, options) {
139
184
  const calls = callsOf(lines, options.bodies);
140
185
  const byEnd = [...calls].sort((a, b) => a.endSeq - b.endSeq);
141
186
  const tools = toolCallsOf(calls);
142
187
  const results = toolResultsOf(calls, lines);
143
188
  const keys = tools.map((t) => `${t.tool}\n${t.fp}`);
189
+ const turn = turnsOf(calls, tools);
144
190
  const first = lines[0] ? JSON.parse(lines[0]) : null;
145
191
  const open = first?.kind === "session.open" ? first.meta : {};
146
192
  const out = [];
147
- // D1: the 3rd / 5th occurrence of the same call within the last 20 tool calls.
193
+ // D1: the 3rd / 5th occurrence of the same call within the last 20 tool calls of one turn: the
194
+ // same lookup asked for again in a person's next message isn't a loop.
148
195
  const d1 = new Set();
149
196
  const repeat = [];
197
+ let turnStart = 0;
150
198
  keys.forEach((key, i) => {
151
- const count = keys.slice(Math.max(0, i - WINDOW + 1), i + 1).filter((k) => k === key).length;
199
+ if (i > 0 && turn[i] !== turn[i - 1])
200
+ turnStart = i;
201
+ const from = Math.max(turnStart, i - WINDOW + 1);
202
+ const count = keys.slice(from, i + 1).filter((k) => k === key).length;
152
203
  repeat.push(count >= 2);
153
204
  for (const [at, severity] of [
154
205
  [3, "caution"],
155
206
  [5, "warning"],
156
207
  ])
157
- if (count === at && !d1.has(`${key}\n${at}`)) {
158
- d1.add(`${key}\n${at}`);
208
+ if (count === at && !d1.has(`${turn[i]}\n${key}\n${at}`)) {
209
+ d1.add(`${turn[i]}\n${key}\n${at}`);
159
210
  const t = tools[i];
160
211
  out.push(f("D1_REPEAT_CALL", severity, { tool: t.tool, fingerprint: t.fp, count: at }, `The same ${t.tool} call ${at} times in the last ${WINDOW} tool calls.`));
161
212
  }
@@ -165,7 +216,7 @@ export function detectors(lines, options) {
165
216
  for (let i = 0; i < keys.length;) {
166
217
  let found = 0;
167
218
  for (let n = 2; n <= 4 && !found; n++) {
168
- if (i + 3 * n > keys.length)
219
+ if (i + 3 * n > keys.length || turn[i] !== turn[i + 3 * n - 1])
169
220
  break;
170
221
  const gram = keys.slice(i, i + n);
171
222
  if (new Set(gram).size === 1)
package/dist/cli.js CHANGED
@@ -33,7 +33,8 @@ const USAGE = `usage (spec/cli.md):
33
33
  blackbox erase <session_id>
34
34
  blackbox approvals <session_id>
35
35
  blackbox decide <session_id> <approval_id> approve|reject [--note <text>]
36
- blackbox drain [--spool-dir <dir>] (ships what a stopped agent left in its spool)
36
+ blackbox drain [--spool-dir <dir>] [--close] (ships what a stopped agent left in its spool;
37
+ --close then closes each session as abandoned)
37
38
  blackbox analyze <bundle.json> [--bodies <dir>] [--prices <file>]
38
39
  blackbox verify-certificate <certificate.json> [--did <did:key:…>]
39
40
  blackbox investigate <bundle.json> [--bodies <dir>] [--directives <file>] [--identity <file>] [--proofs] [--out <prefix>]
@@ -1102,7 +1103,7 @@ async function tailCommand(rest) {
1102
1103
  }
1103
1104
  /** Audit K5: ships what stopped agents left in the spool (BLACKBOX_URL; no key: the kept tokens). */
1104
1105
  async function drainCommand(rest) {
1105
- const { flags, positional } = parse(rest, []);
1106
+ const { flags, positional } = parse(rest, ["--close"]);
1106
1107
  const url = process.env.BLACKBOX_URL;
1107
1108
  if (positional.length || !url)
1108
1109
  return usage();
@@ -1110,6 +1111,7 @@ async function drainCommand(rest) {
1110
1111
  const results = await drainSpools({
1111
1112
  url,
1112
1113
  ...(flags["spool-dir"] ? { spoolDir: flags["spool-dir"] } : {}),
1114
+ ...(flags.close ? { close: true } : {}),
1113
1115
  });
1114
1116
  process.stdout.write(`${JSON.stringify({ ok: results.every((r) => r.shipped), sessions: results })}\n`);
1115
1117
  return results.every((r) => r.shipped) ? 0 : 2;
@@ -3,6 +3,8 @@ export interface DrainResult {
3
3
  shipped: boolean;
4
4
  /** Events the gateway still doesn't have (0 when shipped). */
5
5
  left: number;
6
+ /** With `close`: whether the session was closed. */
7
+ closed?: boolean;
6
8
  }
7
9
  /** Only for agents that have stopped: a running agent ships its own spool. Never throws. */
8
10
  export declare function drainSpools(options: {
@@ -10,4 +12,6 @@ export declare function drainSpools(options: {
10
12
  spoolDir?: string;
11
13
  timeoutMs?: number;
12
14
  logger?: (message: string) => void;
15
+ /** LA.5: close each session once shipped, as `abandoned`. */
16
+ close?: boolean;
13
17
  }): Promise<DrainResult[]>;
@@ -1,7 +1,7 @@
1
1
  // Audit K5: ships the events a stopped agent left in its spool. The SDK keeps the token of a session
2
2
  // it opened itself next to the spool (`<id>.token`, mode 0600), since only it knew the token; this
3
- // reads each one and ships what the gateway hasn't acknowledged. The session isn't closed: the
4
- // gateway already recorded the lost contact, and an operator closes it (or it lapses, spec/api.md).
3
+ // reads each one and ships what the gateway hasn't acknowledged. With `close` (LA.5) each shipped
4
+ // session is then closed as `abandoned`, with its own token; without it an operator closes it.
5
5
  import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
6
6
  import { tmpdir } from "node:os";
7
7
  import { join } from "node:path";
@@ -26,10 +26,18 @@ export async function drainSpools(options) {
26
26
  }, id, token);
27
27
  const shipped = await s.flush({ timeoutMs: options.timeoutMs ?? 30_000 });
28
28
  const left = s.stats.recorded - s.stats.acked;
29
+ const closed = shipped && options.close
30
+ ? await s.close("abandoned", "the agent stopped; its spool was drained")
31
+ : undefined;
29
32
  s.stop();
30
33
  if (shipped)
31
34
  rmSync(join(dir, file), { force: true });
32
- out.push({ session_id: id, shipped, left: shipped ? 0 : Math.max(0, left) });
35
+ out.push({
36
+ session_id: id,
37
+ shipped,
38
+ left: shipped ? 0 : Math.max(0, left),
39
+ ...(closed === undefined ? {} : { closed }),
40
+ });
33
41
  }
34
42
  return out;
35
43
  }
@@ -167,11 +167,14 @@ export declare class BlackboxSession {
167
167
  /** A session that records nothing. `reason`, when opening failed, goes to `stats.lastError` (audit K1). */
168
168
  static disabled(options: SessionOptions, reason?: string): BlackboxSession;
169
169
  private readonly options;
170
+ private readonly serverNumbering;
170
171
  readonly id: string;
171
172
  readonly token: string;
172
173
  constructor(options: SessionOptions, id: string, token: string, enabled?: boolean,
173
174
  /** Audit K5: this SDK opened the session, so only it knows the token: keep it by the spool. */
174
- keepToken?: boolean);
175
+ keepToken?: boolean,
176
+ /** spec/sdk.md §1: events go unnumbered, each with an event_id; the server numbers them. */
177
+ serverNumbering?: boolean);
175
178
  event(type: string, name?: string, data?: Record<string, unknown>, options?: {
176
179
  eventId?: string;
177
180
  /** spec/sdk.md §1.1: an image the event's body is (see `screen`). */
@@ -59,23 +59,30 @@ export function imageType(image) {
59
59
  const LOCK_WAIT_MS = 2_000;
60
60
  const LOCK_STALE_MS = 10_000;
61
61
  const spoolDirOf = (options) => options.spoolDir ?? process.env.BLACKBOX_SPOOL_DIR ?? join(tmpdir(), "zanii-blackbox-spool");
62
- /** spec/sdk.md §1: a session joined without its spool (a new process, a serverless function)
63
- * numbers its events from the server's `next`; from 0, they'd be skipped as already recorded. */
64
- async function startAtServerNext(options, id, token) {
62
+ /** spec/sdk.md §1: joining a session another process may write to (a serverless function, a
63
+ * second worker). When the server numbers events itself, this SDK sends them unnumbered with an
64
+ * event_id, so two writers never collide: true then. Otherwise, without a spool, it numbers from
65
+ * the server's `next` (from 0, its events would be skipped as already recorded). */
66
+ async function joinSession(options, id, token) {
65
67
  const dir = spoolDirOf(options);
66
- if (existsSync(join(dir, `${id}.jsonl`)) || existsSync(join(dir, `${id}.ack`)))
67
- return;
68
68
  try {
69
69
  const r = await send("GET", options.url, `/v1/sessions/${id}/state`, token);
70
- const n = r.status === 200 ? r.json.sdk_next : undefined;
71
- if (typeof n === "number" && Number.isInteger(n) && n > 0) {
70
+ if (r.status !== 200)
71
+ return false;
72
+ const numbering = r.json.sdk_numbering;
73
+ if (Array.isArray(numbering) && numbering.includes("server"))
74
+ return true;
75
+ const n = r.json.sdk_next;
76
+ const have = existsSync(join(dir, `${id}.jsonl`)) || existsSync(join(dir, `${id}.ack`));
77
+ if (typeof n === "number" && Number.isInteger(n) && n > 0 && !have) {
72
78
  mkdirSync(dir, { recursive: true });
73
79
  writeFileSync(join(dir, `${id}.ack`), JSON.stringify({ next: n, offset: 0, gen: 0 }));
74
80
  }
75
81
  }
76
82
  catch {
77
- // offline: the spool keeps the events, numbered from 0
83
+ // offline: the spool keeps the events, numbered locally
78
84
  }
85
+ return false;
79
86
  }
80
87
  /** Opens (or joins) a session. Never throws: on failure it returns a disabled session and logs why. */
81
88
  export async function session(options) {
@@ -114,8 +121,8 @@ export async function session(options) {
114
121
  token = String(r.json.token);
115
122
  return new BlackboxSession(options, id, token, true, true);
116
123
  }
117
- await startAtServerNext(options, id, token);
118
- return new BlackboxSession(options, id, token);
124
+ const serverNumbering = await joinSession(options, id, token);
125
+ return new BlackboxSession(options, id, token, true, false, serverNumbering);
119
126
  }
120
127
  catch (error) {
121
128
  log(`blackbox: session disabled: ${message(error)}`);
@@ -154,11 +161,15 @@ export class BlackboxSession {
154
161
  return s;
155
162
  }
156
163
  options;
164
+ serverNumbering;
157
165
  id;
158
166
  token;
159
167
  constructor(options, id, token, enabled = true,
160
168
  /** Audit K5: this SDK opened the session, so only it knows the token: keep it by the spool. */
161
- keepToken = false) {
169
+ keepToken = false,
170
+ /** spec/sdk.md §1: events go unnumbered, each with an event_id; the server numbers them. */
171
+ serverNumbering = false) {
172
+ this.serverNumbering = serverNumbering;
162
173
  this.options = options;
163
174
  this.id = id;
164
175
  this.token = token;
@@ -219,7 +230,11 @@ export class BlackboxSession {
219
230
  sdk_seq: this.nextSeq,
220
231
  type,
221
232
  ...(name === undefined ? {} : { name }),
222
- ...(eventId === undefined ? {} : { event_id: eventId }),
233
+ ...(eventId !== undefined
234
+ ? { event_id: eventId }
235
+ : this.serverNumbering
236
+ ? { event_id: `evt_${randomUUID().replaceAll("-", "")}` } // a resend is harmless
237
+ : {}),
223
238
  ts: new Date().toISOString(),
224
239
  ...(kept === undefined ? {} : { data: kept }),
225
240
  ...(options.attachment
@@ -692,18 +707,34 @@ export class BlackboxSession {
692
707
  });
693
708
  if (lines.length === 0)
694
709
  return;
695
- const events = lines.map((l) => JSON.parse(l.text));
710
+ let events = lines.map((l) => JSON.parse(l.text));
711
+ // spec/sdk.md §1: lines with an event_id go unnumbered; a line without one (left in the spool
712
+ // by a run that numbered its own) goes numbered, as it was written. Never mixed in one batch.
713
+ const withId = (e) => e.event_id !== undefined;
714
+ const unnumbered = this.serverNumbering && withId(events[0]);
715
+ const cut = events.findIndex((e) => withId(e) !== withId(events[0]));
716
+ if (cut > 0) {
717
+ lines.splice(cut);
718
+ events = events.slice(0, cut);
719
+ }
720
+ const sent = unnumbered ? events.map(({ sdk_seq: _, ...rest }) => rest) : events;
696
721
  const r = await post(this.options.url, `/v1/sessions/${this.id}/sdk-events`, this.token, {
697
- events,
722
+ events: sent,
698
723
  });
699
724
  const next = typeof r.json.next === "number" ? r.json.next : null;
700
725
  if (next === null)
701
726
  throw new Rejected(r.status);
702
727
  this.withLock(() => {
703
728
  // Another process may have acknowledged or compacted meanwhile: then our offsets are stale.
704
- if ((this.ack.gen ?? 0) === from.gen &&
705
- this.ack.offset === from.offset &&
706
- next > this.ack.next) {
729
+ const fresh = (this.ack.gen ?? 0) === from.gen && this.ack.offset === from.offset;
730
+ if (fresh && unnumbered && r.status === 200) {
731
+ // spec/sdk.md §1: the server numbered them; every line sent is recorded (or was)
732
+ const done = Math.max(...events.map((e) => e.sdk_seq)) + 1;
733
+ const offset = from.offset + lines.reduce((n, l) => n + l.bytes, 0);
734
+ this.ack = { next: done, offset, gen: from.gen };
735
+ writeFileSync(this.ackFile, JSON.stringify(this.ack));
736
+ }
737
+ else if (fresh && !unnumbered && next > this.ack.next) {
707
738
  // Advance past every sent line the server now has (sdk_seq < next).
708
739
  let offset = from.offset;
709
740
  for (const [i, e] of events.entries())
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.6.0";
1
+ export declare const VERSION = "0.8.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // The SDK version, on its own so the CLI can print it without loading the whole SDK.
2
- export const VERSION = "0.6.0";
2
+ export const VERSION = "0.8.0";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zanii/blackbox",
3
3
  "license": "Apache-2.0",
4
- "version": "0.6.0",
4
+ "version": "0.8.0",
5
5
  "description": "The flight recorder for AI agents: sessions, a zero-loss spool, framework hooks, approvals, and offline verification of the gateway's hash-chained record.",
6
6
  "keywords": [
7
7
  "ai-agents",