@agent-watch-ai/otel 0.0.0-stage → 0.1.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.
@@ -0,0 +1,188 @@
1
+ import { createHash } from 'node:crypto';
2
+ /**
3
+ * A Node port of `@agent-watch/fingerprint` scheme 1, digest for digest with the Python port.
4
+ *
5
+ * Copied from agent-watch-core `packages/fingerprint/src/fingerprint.ts` at 207ba472, the commit
6
+ * `conformance/runtime/fingerprint-vectors.json` was cut from, with one addition: the
7
+ * `MAX_TOOL_DEPTH` bound the Python port states, so an over-deep tool definition is refused at
8
+ * the same depth in both ports instead of at whatever depth the stack runs out.
9
+ */
10
+ /** Names the rules in the shared vectors. Digests from different schemes never compare. */
11
+ export const FINGERPRINT_SCHEME = 'agent-watch-fingerprint/1';
12
+ /** How deep a tool definition may nest before canonical JSON refuses it. Python holds the same number. */
13
+ export const MAX_TOOL_DEPTH = 100;
14
+ /** The first message is not `system` or `developer`, or there are no messages. */
15
+ export const NO_INSTRUCTIONS = 'no_instructions';
16
+ /** The leading instruction messages normalise to the empty string. */
17
+ export const EMPTY_INSTRUCTIONS = 'empty_instructions';
18
+ const SHINGLE_TOKENS = 8;
19
+ const DIGEST_BYTES = 16;
20
+ const MAX_PREFIX_SHINGLES = 512;
21
+ const INSTRUCTION_ROLES = new Set(['system', 'developer']);
22
+ // Unicode White_Space. The lookbehind anchors the match at the start of the run, so a long run stays linear.
23
+ const TRAILING_SPACE = /(?<!\p{White_Space})\p{White_Space}+$/u;
24
+ const SPACE_RUN = /\p{White_Space}+/u;
25
+ // "abc "\n "def", 'abc ' +\n 'def', f"abc "\\\n f"def": concatenation, so the halves join directly.
26
+ const CONCAT_SEAM = /["'`][ \t]*(?:[+\\][ \t]*)?\n[ \t\n]*(?:\+[ \t]*)?[bfruBFRU]{0,2}["'`]/g;
27
+ // "abc",\n "def" inside an array: elements are joined by a separator at runtime, so a space.
28
+ const ELEMENT_SEAM = /["'`][ \t]*,[ \t]*\n[ \t\n]*[bfruBFRU]{0,2}["'`]/g;
29
+ const WHITESPACE_ESCAPE = /(?<!\\)\\+[ntr]/g;
30
+ const QUOTE_ESCAPE = /(?<!\\)\\+(?=["'`])/g;
31
+ const BACKSLASH_RUN = /\\{2,}/g;
32
+ const QUOTE = /["'`]/g;
33
+ /** A tool definition nested deeper than `MAX_TOOL_DEPTH`. */
34
+ export class ToolDepthExceeded extends RangeError {
35
+ }
36
+ /**
37
+ * NFC, CRLF to LF, trailing whitespace off every line and off the end. Never lowercases.
38
+ * @param text Prompt or source text as the platform stored it.
39
+ * @returns The text every digest here is taken over.
40
+ */
41
+ export function normalise(text) {
42
+ return text
43
+ .normalize('NFC')
44
+ .replaceAll('\r\n', '\n')
45
+ .split('\n')
46
+ .map((line) => line.replace(TRAILING_SPACE, ''))
47
+ .join('\n')
48
+ .replace(TRAILING_SPACE, '');
49
+ }
50
+ /**
51
+ * The normalised leading `system`/`developer` run, joined by a blank line. Producers shingle
52
+ * exactly this text; any other join changes the shingles.
53
+ * @param messages Messages in order, in whatever shape the platform stored them.
54
+ * @returns The prefix text, or `null` when the first message is not an instruction.
55
+ */
56
+ export function prefixText(messages) {
57
+ const end = messages.findIndex((message) => !isRecord(message) || !INSTRUCTION_ROLES.has(message.role));
58
+ const leading = end === -1 ? messages : messages.slice(0, end);
59
+ if (leading.length === 0) {
60
+ return null;
61
+ }
62
+ return normalise(leading.map((message) => contentText(isRecord(message) ? message.content : undefined)).join('\n\n'));
63
+ }
64
+ /**
65
+ * sha256 hex of `prefixText`, or the reason there is none.
66
+ * @param messages Messages in order.
67
+ * @returns The digest with a `null` cause, or a `null` digest with its cause.
68
+ */
69
+ export function prefixDigest(messages) {
70
+ return prefixTextDigest(prefixText(messages));
71
+ }
72
+ /**
73
+ * `prefixDigest` from text `prefixText` already returned, so a caller that also shingles it
74
+ * builds the text once.
75
+ * @param text `prefixText`'s output.
76
+ * @returns The digest with a `null` cause, or a `null` digest with its cause.
77
+ */
78
+ export function prefixTextDigest(text) {
79
+ if (text === null) {
80
+ return { digest: null, cause: NO_INSTRUCTIONS };
81
+ }
82
+ if (text === '') {
83
+ return { digest: null, cause: EMPTY_INSTRUCTIONS };
84
+ }
85
+ return { digest: sha256(text).toString('hex'), cause: null };
86
+ }
87
+ /**
88
+ * sha256 hex of the tool definitions as canonical JSON, sorted by (name, JSON) and newline-joined.
89
+ * @param tools Tool definitions as parsed JSON, OpenAI (`function.name`) or Anthropic (`name`) shape.
90
+ * @returns 64 hex characters, or `null` when there are no tools.
91
+ * @throws {ToolDepthExceeded} A definition nests past `MAX_TOOL_DEPTH`.
92
+ */
93
+ export function toolsetDigest(tools) {
94
+ if (tools.length === 0) {
95
+ return null;
96
+ }
97
+ const entries = tools.map((tool) => ({ name: toolName(tool), json: canonicalJson(tool, 0) }));
98
+ entries.sort((a, b) => compareCodeUnits(a.name, b.name) || compareCodeUnits(a.json, b.json));
99
+ return sha256(entries.map((entry) => entry.json).join('\n')).toString('hex');
100
+ }
101
+ /**
102
+ * 16-byte hex digests of every 8-token window, deduplicated in order, at most 512.
103
+ * @param text Prompt text, normally `prefixText`'s output.
104
+ * @returns Up to 512 digests of 32 hex characters each.
105
+ */
106
+ export function shingleDigests(text) {
107
+ return windowDigests(tokens(text), MAX_PREFIX_SHINGLES);
108
+ }
109
+ function contentText(content) {
110
+ if (typeof content === 'string') {
111
+ return content;
112
+ }
113
+ if (!Array.isArray(content)) {
114
+ return '';
115
+ }
116
+ return content
117
+ .flatMap((part) => (isRecord(part) && part.type === 'text' && typeof part.text === 'string' ? [part.text] : []))
118
+ .join('\n\n');
119
+ }
120
+ function toolName(tool) {
121
+ if (!isRecord(tool)) {
122
+ return '';
123
+ }
124
+ if (typeof tool.name === 'string') {
125
+ return tool.name;
126
+ }
127
+ const fn = tool.function;
128
+ return isRecord(fn) && typeof fn.name === 'string' ? fn.name : '';
129
+ }
130
+ // JSON.stringify already writes numbers as Number::toString, escapes lone surrogates (ES2019) and
131
+ // leaves non-ASCII as is, which is exactly what the Python port reimplements by hand.
132
+ function canonicalJson(value, depth) {
133
+ if (depth > MAX_TOOL_DEPTH) {
134
+ throw new ToolDepthExceeded(`tool definition nests deeper than ${String(MAX_TOOL_DEPTH)} levels`);
135
+ }
136
+ if (Array.isArray(value)) {
137
+ return `[${value.map((item) => canonicalJson(item, depth + 1)).join(',')}]`;
138
+ }
139
+ if (isRecord(value)) {
140
+ const keys = Object.keys(value).sort(compareCodeUnits);
141
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key], depth + 1)}`).join(',')}}`;
142
+ }
143
+ return JSON.stringify(value) ?? 'null';
144
+ }
145
+ /**
146
+ * The words a window is taken over. Exported for the source-file runner in the tests.
147
+ * @param text Prompt or source text.
148
+ * @returns Its tokens in order.
149
+ */
150
+ export function tokens(text) {
151
+ return normalise(text)
152
+ .replace(CONCAT_SEAM, '')
153
+ .replace(ELEMENT_SEAM, ' ')
154
+ .replace(WHITESPACE_ESCAPE, ' ')
155
+ .replace(QUOTE_ESCAPE, '')
156
+ .replace(BACKSLASH_RUN, '\\')
157
+ .replaceAll('{{', '{')
158
+ .replaceAll('}}', '}')
159
+ .replace(QUOTE, '')
160
+ .split(SPACE_RUN)
161
+ .filter((token) => token !== '');
162
+ }
163
+ /**
164
+ * 16-byte hex digests of every 8-token window, deduplicated in order.
165
+ * @param words `tokens`' output.
166
+ * @param cap At most this many digests.
167
+ * @returns The digests.
168
+ */
169
+ export function windowDigests(words, cap) {
170
+ const digests = new Set();
171
+ for (let i = 0; i + SHINGLE_TOKENS <= words.length && digests.size < cap; i++) {
172
+ digests.add(sha256(words.slice(i, i + SHINGLE_TOKENS).join(' ')).subarray(0, DIGEST_BYTES).toString('hex'));
173
+ }
174
+ return [...digests];
175
+ }
176
+ // Node's UTF-8 encoder turns a lone surrogate into U+FFFD, which the Python port copies.
177
+ function sha256(text) {
178
+ return createHash('sha256').update(text, 'utf8').digest();
179
+ }
180
+ function compareCodeUnits(a, b) {
181
+ if (a === b) {
182
+ return 0;
183
+ }
184
+ return a < b ? -1 : 1;
185
+ }
186
+ function isRecord(value) {
187
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
188
+ }
package/dist/gate.d.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The runtime gate: before an LLM span's first HTTP request leaves the process, ask Agent Watch
3
+ * whether the agent may make it (plan Q1, Q2, D7). An LLM span is one the processor reports
4
+ * (`isLlmSpan`), so a refused call is always reported as one; tool, agent and embedding spans
5
+ * are never gated.
6
+ *
7
+ * What it must never do:
8
+ * 1. Fail the call for any reason but a `block` answer. Every other outcome (slow, down, a status
9
+ * that is not 2xx, a body it cannot read, a bug here) lets the call through and is counted as
10
+ * a fail-open, reported on the next decision request for the same agent. There is no
11
+ * fail-closed path and no switch for one.
12
+ * 2. Wait longer than `GATE_TIMEOUT_MILLIS`. A held answer costs no request at all, and neither
13
+ * does a call after a fail-open: the agent is held at allow for a while (`FAIL_OPEN_HOLD_MILLIS`).
14
+ * 3. Send content. The request names the agent (the span name), the service, the model and the
15
+ * deployment environment, and carries the counts it owes. Nothing from a message, tool or response.
16
+ *
17
+ * What it owes travels as one report batch per request (contract v2, AWT-335): the batch has a
18
+ * `report_id` and is sent again, unchanged, until a request carrying it is answered, so the
19
+ * platform writes it once however often it is resent. What is counted meanwhile waits for the
20
+ * next batch.
21
+ */
22
+ /** The longest a call waits for an answer: the coding edge's `DEFAULT_ENFORCEMENT_TIMEOUT_MS`. */
23
+ export declare const GATE_TIMEOUT_MILLIS = 300;
24
+ /**
25
+ * How long a decision request may run after the call stopped waiting for it. Not cancelled at the
26
+ * gate timeout: a far-away gateway answers a cold request in more than 300 ms, and cancelling
27
+ * would throw the connection away and make every attempt cold. Its late answer is held.
28
+ */
29
+ export declare const LATE_ANSWER_MILLIS = 5000;
30
+ /** A `hold_ms` above this is held for this long: a stale refusal must not outlive a cap raise by much. */
31
+ export declare const MAX_HOLD_MILLIS = 60000;
32
+ /** How long a fail-open holds an allow: Agent Watch slow, down or failing. */
33
+ export declare const FAIL_OPEN_HOLD_MILLIS = 30000;
34
+ /** How long a 401, 403 or 404 holds an allow: the route is missing or the token lacks the scope. */
35
+ export declare const FAIL_OPEN_AUTH_HOLD_MILLIS = 300000;
36
+ /**
37
+ * Agents remembered at once; past it the least recently used one with nothing in flight and
38
+ * nothing owed is forgotten. One that owes reports is kept up to twice this many.
39
+ */
40
+ export declare const MAX_AGENTS = 1000;
41
+ /** Token endpoints a model call depends on (Vertex on a workload identity): never gated. */
42
+ export declare const TOKEN_HOSTS: ReadonlySet<string>;
43
+ /** Why a call went through without an answer. Values are core's `RuntimeFailOpenReason`. */
44
+ export type FailOpenReason = 'timeout' | 'unreachable' | 'http_status' | 'unreadable' | 'internal';
45
+ /** Who a decision is about. `null` fields are left out of the request. Held per agent and environment. */
46
+ export interface DecisionSubject {
47
+ readonly agentName: string;
48
+ readonly serviceName: string | null;
49
+ readonly model: string | null;
50
+ readonly deploymentEnvironment: string | null;
51
+ }
52
+ type FetchInput = Parameters<typeof fetch>[0];
53
+ type FetchInit = Parameters<typeof fetch>[1];
54
+ /** Asks the decision route, holds its answers per agent, and gates `fetch` requests made inside LLM spans. */
55
+ export declare class RuntimeGate {
56
+ private readonly endpoint;
57
+ private readonly agents;
58
+ private readonly spans;
59
+ /**
60
+ * Point the gate at a decision route.
61
+ * @param token SDK token scoped `ingest:production`.
62
+ * @param endpoint Ingest base URL; `v1/enforcement/runtime-decision` is joined onto it.
63
+ */
64
+ constructor(token: string, endpoint: string);
65
+ /**
66
+ * Whether this agent's next call is refused. Never rejects, and settles within the gate timeout.
67
+ * @param subject The agent and what is stated about it.
68
+ * @returns `true` to refuse the call.
69
+ */
70
+ check(subject: DecisionSubject): Promise<boolean>;
71
+ /**
72
+ * `fetch`, gated: the first request inside an LLM span waits for the span's decision; every
73
+ * later request in that span gets the same one. Anything else goes straight through.
74
+ * @param original The `fetch` this one replaced.
75
+ * @param input As `fetch`.
76
+ * @param init As `fetch`.
77
+ * @returns What `original` returns, or a rejection with `AgentWatchRefusal`, or with
78
+ * `init.signal`'s reason when it aborts while the decision is awaited.
79
+ */
80
+ fetch(original: typeof fetch, input: FetchInput, init?: FetchInit): Promise<Response>;
81
+ /** Release the sockets. Uninstall it first: a closed gate is never consulted. */
82
+ close(): void;
83
+ private agentFor;
84
+ private evict;
85
+ private decisionFor;
86
+ private decide;
87
+ }
88
+ /**
89
+ * Make `gate` the one `globalThis.fetch` consults, until it is uninstalled. Wraps `fetch` once per
90
+ * process, however many processors or copies of this package register; the most recent live gate
91
+ * is the one consulted.
92
+ * @param gate The gate to consult.
93
+ */
94
+ export declare function installGate(gate: RuntimeGate): void;
95
+ /**
96
+ * Stop consulting `gate`; the gate installed before it, if still live, is consulted again. `fetch`
97
+ * stays wrapped and passes through once no gate is live.
98
+ * @param gate The gate being shut down.
99
+ */
100
+ export declare function uninstallGate(gate: RuntimeGate): void;
101
+ /**
102
+ * The fallback for calls the gate does not see (`node:http`, gRPC, other transports): await it
103
+ * before the call. Resolves at once when no processor is registered.
104
+ * @param agentName The agent the call is charged to, as the span would name it.
105
+ * @returns When the call may go ahead.
106
+ * @throws {AgentWatchRefusal} When an Agent Watch budget refuses it.
107
+ */
108
+ export declare function guard(agentName: string): Promise<void>;
109
+ export {};