@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 +10 -0
- package/dist/analysis/detectors.js +56 -5
- package/dist/cli.js +4 -2
- package/dist/session/drain.d.ts +4 -0
- package/dist/session/drain.js +11 -3
- package/dist/session/index.d.ts +4 -1
- package/dist/session/index.js +48 -17
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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
|
-
|
|
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;
|
package/dist/session/drain.d.ts
CHANGED
|
@@ -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[]>;
|
package/dist/session/drain.js
CHANGED
|
@@ -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.
|
|
4
|
-
//
|
|
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({
|
|
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
|
}
|
package/dist/session/index.d.ts
CHANGED
|
@@ -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`). */
|
package/dist/session/index.js
CHANGED
|
@@ -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
|
|
63
|
-
*
|
|
64
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|