@stigg/agent-sdk-js 0.2.0 → 0.3.1
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/dist/bindings/client/providers/anthropic/anthropic-recipe.js +27 -5
- package/dist/bindings/shared/meter-stream.js +56 -35
- package/dist/config.js +22 -1
- package/dist/core/context.js +26 -1
- package/dist/stigg-agent-sdk.d.ts +8 -0
- package/dist/stigg-agent-sdk.js +13 -0
- package/dist/types.d.ts +12 -1
- package/package.json +2 -2
|
@@ -81,10 +81,22 @@ function restate(into, stated) {
|
|
|
81
81
|
* stream produces one measurement rather than one per event.
|
|
82
82
|
*/
|
|
83
83
|
function observeStream(stream, ingest) {
|
|
84
|
-
if (!stream
|
|
84
|
+
if (!stream)
|
|
85
85
|
return;
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
// Anthropic's `Stream` keeps an `iterator` factory on the instance and routes everything through
|
|
87
|
+
// it - `[Symbol.asyncIterator]()` returns `this.iterator()`, and so does `tee()`. Patching the
|
|
88
|
+
// async iterator alone left a teed stream reading the raw source: Anthropic billed the call and
|
|
89
|
+
// nothing here metered it. Patch the factory where there is one, and only the factory, or the
|
|
90
|
+
// vendor's own delegation would run the accounting twice over the same chunks.
|
|
91
|
+
const makeIterator = typeof stream.iterator === 'function'
|
|
92
|
+
? stream.iterator.bind(stream)
|
|
93
|
+
: undefined;
|
|
94
|
+
const iterate = typeof stream[Symbol.asyncIterator] === 'function'
|
|
95
|
+
? stream[Symbol.asyncIterator].bind(stream)
|
|
96
|
+
: undefined;
|
|
97
|
+
if (!makeIterator && !iterate)
|
|
98
|
+
return;
|
|
99
|
+
async function* gated(source) {
|
|
88
100
|
// What the stream has said so far, in the vendor's own shape. Kept flat rather than converted
|
|
89
101
|
// per event: both views of the call - the vendor's names and the normalized counters - are
|
|
90
102
|
// derived from it once at the end, so neither can be accumulated out of step with the other.
|
|
@@ -95,7 +107,7 @@ function observeStream(stream, ingest) {
|
|
|
95
107
|
// What the stream actually produced, for the case where it never gets to say.
|
|
96
108
|
let chars = 0;
|
|
97
109
|
try {
|
|
98
|
-
for await (const event of
|
|
110
|
+
for await (const event of source) {
|
|
99
111
|
if (event?.type === 'message_start')
|
|
100
112
|
stated = restate(stated, event.message?.usage);
|
|
101
113
|
if (event?.type === 'message_delta') {
|
|
@@ -131,7 +143,17 @@ function observeStream(stream, ingest) {
|
|
|
131
143
|
...(estimated ? { estimated: true } : {}),
|
|
132
144
|
});
|
|
133
145
|
}
|
|
134
|
-
}
|
|
146
|
+
}
|
|
147
|
+
if (makeIterator) {
|
|
148
|
+
stream.iterator = function gatedIterator() {
|
|
149
|
+
return gated({ [Symbol.asyncIterator]: () => makeIterator() });
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
stream[Symbol.asyncIterator] = function gatedAsyncIterator() {
|
|
154
|
+
return gated(iterate());
|
|
155
|
+
};
|
|
156
|
+
}
|
|
135
157
|
}
|
|
136
158
|
exports.anthropicRecipe = {
|
|
137
159
|
vendor: 'anthropic',
|
|
@@ -27,13 +27,19 @@ function meterStream(stream, collect, onComplete) {
|
|
|
27
27
|
// then the patched one - and either recurse or count every chunk twice.
|
|
28
28
|
const iterate = typeof stream[Symbol.asyncIterator] === 'function' ? stream[Symbol.asyncIterator].bind(stream) : undefined;
|
|
29
29
|
const advance = typeof stream.next === 'function' ? stream.next.bind(stream) : undefined;
|
|
30
|
+
// The vendor stream classes - Anthropic's and OpenAI's both - keep an `iterator` *factory* on
|
|
31
|
+
// the instance, and everything else goes through it: `[Symbol.asyncIterator]()` returns
|
|
32
|
+
// `this.iterator()`, and so does `tee()`. Patching the async iterator alone therefore leaves
|
|
33
|
+
// `tee()` reading the raw source, which is how a teed stream was charged by the vendor and
|
|
34
|
+
// metered at nothing here.
|
|
35
|
+
const makeIterator = typeof stream.iterator === 'function' ? stream.iterator.bind(stream) : undefined;
|
|
30
36
|
// Closing by hand ends a stream as surely as running it out, so these are captured too - and
|
|
31
37
|
// for the same reason: `source()` drives them, and binding them after the patch would have it
|
|
32
38
|
// calling itself.
|
|
33
39
|
const closers = ['return', 'throw']
|
|
34
40
|
.map((key) => ({ key, original: typeof stream[key] === 'function' ? stream[key].bind(stream) : undefined }))
|
|
35
41
|
.filter((entry) => !!entry.original);
|
|
36
|
-
if (!iterate && !advance)
|
|
42
|
+
if (!iterate && !advance && !makeIterator)
|
|
37
43
|
return;
|
|
38
44
|
/** Fires once. A stream iterated twice is still one completed stream, and one measurement. */
|
|
39
45
|
let completed = false;
|
|
@@ -108,44 +114,59 @@ function meterStream(stream, collect, onComplete) {
|
|
|
108
114
|
}
|
|
109
115
|
};
|
|
110
116
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
117
|
+
/**
|
|
118
|
+
* One pass over an iterator, metering as it goes. Shared by both entry points below so that the
|
|
119
|
+
* accounting is written once: whichever door the caller came through, the usage is read the same
|
|
120
|
+
* way and `finish()` fires on the same terms.
|
|
121
|
+
*/
|
|
122
|
+
async function* metered(it) {
|
|
123
|
+
// Whether the source ran itself out, as opposed to the consumer walking away from it. Only
|
|
124
|
+
// the second case owes it a `return()` - see the `finally`.
|
|
125
|
+
let exhausted = false;
|
|
126
|
+
try {
|
|
127
|
+
while (true) {
|
|
128
|
+
const { value, done } = await it.next();
|
|
129
|
+
if (done) {
|
|
130
|
+
exhausted = true;
|
|
131
|
+
return;
|
|
126
132
|
}
|
|
133
|
+
observe(value);
|
|
134
|
+
yield value;
|
|
127
135
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
// Closes the vendor's stream when the consumer broke out early, which is what `for await`
|
|
139
|
+
// did on the way out. Without it an abandoned stream holds its connection open.
|
|
140
|
+
//
|
|
141
|
+
// Only then, though. The iteration protocol closes an iterator on *abrupt* completion and
|
|
142
|
+
// deliberately not when it ran itself out, so a source that already finished is never
|
|
143
|
+
// asked to close - driving `next()` by hand means saying that explicitly rather than
|
|
144
|
+
// inheriting it. Calling `return()` on a spent iterator is a no-op for a plain generator,
|
|
145
|
+
// but a vendor is free to implement it as "abort the request", and aborting a stream that
|
|
146
|
+
// has already delivered its last chunk is a cancellation nobody asked for.
|
|
147
|
+
if (!exhausted) {
|
|
148
|
+
try {
|
|
149
|
+
await it.return?.();
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// A source that objects to being closed must not mask the caller's own exit path.
|
|
145
153
|
}
|
|
146
|
-
// Meter what was actually produced, even if the consumer broke out of the loop early.
|
|
147
|
-
finish();
|
|
148
154
|
}
|
|
155
|
+
// Meter what was actually produced, even if the consumer broke out of the loop early.
|
|
156
|
+
finish();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (makeIterator) {
|
|
160
|
+
// The factory, not the async iterator: `tee()` calls this directly and never touches
|
|
161
|
+
// `[Symbol.asyncIterator]`, and the vendor's own `[Symbol.asyncIterator]` calls it too - so
|
|
162
|
+
// one patch here meters every exit, and patching both would count each chunk twice.
|
|
163
|
+
stream.iterator = function meteredIterator() {
|
|
164
|
+
return metered(makeIterator());
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
else if (iterate) {
|
|
168
|
+
stream[Symbol.asyncIterator] = function meteredAsyncIterator() {
|
|
169
|
+
return metered(source());
|
|
149
170
|
};
|
|
150
171
|
}
|
|
151
172
|
}
|
package/dist/config.js
CHANGED
|
@@ -88,7 +88,7 @@ function resolveConfig(options = {}) {
|
|
|
88
88
|
"the Node server SDK's entitlement cache, which invalidates on updates and writes reported " +
|
|
89
89
|
'usage back, so there is no TTL to size. Configure it with `cache` instead.');
|
|
90
90
|
}
|
|
91
|
-
|
|
91
|
+
const config = {
|
|
92
92
|
...parsed,
|
|
93
93
|
cache: options.cache,
|
|
94
94
|
client: options.client,
|
|
@@ -96,4 +96,25 @@ function resolveConfig(options = {}) {
|
|
|
96
96
|
// Never silent, never fatal. Replace it to route into your own telemetry.
|
|
97
97
|
onError: options.onError ?? ((error, context) => logger.warn(`[stigg] ${context.operation} failed: ${error.message}`)),
|
|
98
98
|
};
|
|
99
|
+
// The key reads exactly as before and stops being *printed*. `config` is a public property, so
|
|
100
|
+
// `console.log(stigg)`, `util.inspect`, `JSON.stringify` and any structured logger that walks
|
|
101
|
+
// the object were all putting a live server key into whatever collects those lines. Hiding the
|
|
102
|
+
// one field rather than the whole object keeps `baseUrl` and the rest debuggable.
|
|
103
|
+
Object.defineProperty(config, 'apiKey', { value: parsed.apiKey, enumerable: false, writable: true });
|
|
104
|
+
// Hiding our own field is not enough. A caller who passes `client` hands over an object that
|
|
105
|
+
// carries its own copy of the key, so `console.log(stigg.config)` reached it through there -
|
|
106
|
+
// and no amount of hiding `config.apiKey` helps with somebody else's object. So the config
|
|
107
|
+
// renders itself: what is useful to debug, and a placeholder where a secret would be.
|
|
108
|
+
//
|
|
109
|
+
// It cannot stop `console.log(stigg.config.client)`, which is the caller's own object and theirs
|
|
110
|
+
// to log. What it stops is this SDK's surfaces printing it for them.
|
|
111
|
+
const redacted = () => ({
|
|
112
|
+
...config,
|
|
113
|
+
apiKey: config.apiKey ? '[redacted]' : undefined,
|
|
114
|
+
client: config.client ? '[client supplied by the caller]' : undefined,
|
|
115
|
+
});
|
|
116
|
+
for (const key of ['toJSON', Symbol.for('nodejs.util.inspect.custom')]) {
|
|
117
|
+
Object.defineProperty(config, key, { value: redacted, enumerable: false, writable: false });
|
|
118
|
+
}
|
|
119
|
+
return config;
|
|
99
120
|
}
|
package/dist/core/context.js
CHANGED
|
@@ -75,6 +75,16 @@ function resolveContext(context, correlationId,
|
|
|
75
75
|
// names an agent and its entitlement once for every call through the client, so a typo here
|
|
76
76
|
// governs *everything* by the fallback rather than one call.
|
|
77
77
|
assertKnownContext(context, requireCustomer ? 'stiggContext' : "wrap's stiggContext");
|
|
78
|
+
// A key that deduplicates *one report* cannot be a property of a client. Set at `wrap`, every
|
|
79
|
+
// call through that client reports under the same key, Stigg dedupes them, and every call after
|
|
80
|
+
// the first moves no budget at all - silently, because nothing errors and the usage simply
|
|
81
|
+
// never lands. Refused here rather than ignored, because a caller who passed one meant
|
|
82
|
+
// something by it.
|
|
83
|
+
if (!requireCustomer && context.idempotencyKey !== undefined) {
|
|
84
|
+
throw new Error("wrap's stiggContext cannot set `idempotencyKey` - it deduplicates a single report, so one " +
|
|
85
|
+
'key shared by every call through the client would bill the first and discard the rest. ' +
|
|
86
|
+
'Pass it per call instead, or leave it out and let the vendor request id stand in.');
|
|
87
|
+
}
|
|
78
88
|
const customerId = context.customerId?.trim() ?? '';
|
|
79
89
|
if (!customerId && requireCustomer)
|
|
80
90
|
throw (0, exports.noCustomer)();
|
|
@@ -172,7 +182,22 @@ function overrideContext(base, override) {
|
|
|
172
182
|
let entitlement = base.entitlement;
|
|
173
183
|
let checkedId = base.checkedId;
|
|
174
184
|
if (rebased) {
|
|
175
|
-
|
|
185
|
+
// Absent inherits; present-but-blank throws. `||` used to collapse the two, so a call that
|
|
186
|
+
// named a customer and got the name wrong - an empty string out of a config, a trimmed-away
|
|
187
|
+
// id - was billed to the client's default tenant instead. That is someone else's invoice,
|
|
188
|
+
// and nothing said so. The same mistake on `agentId` has always thrown.
|
|
189
|
+
if (override.customerId !== undefined) {
|
|
190
|
+
// `trim()` on its own turns `null` into "Cannot read properties of null" and a number into
|
|
191
|
+
// "trim is not a function" - the two shapes a JavaScript caller actually passes by accident.
|
|
192
|
+
// Anything that is not a non-empty string gets the message that says what went wrong.
|
|
193
|
+
const named = typeof override.customerId === 'string' ? override.customerId.trim() : '';
|
|
194
|
+
if (!named) {
|
|
195
|
+
throw new Error('stiggContext.customerId is empty - a call that names a customer must name one. Leave ' +
|
|
196
|
+
"the field out to bill the client's own customer; passing an empty value would bill " +
|
|
197
|
+
'that customer for work the call said belonged to someone else.');
|
|
198
|
+
}
|
|
199
|
+
customerId = named;
|
|
200
|
+
}
|
|
176
201
|
if (override.entitlement !== undefined) {
|
|
177
202
|
const invalid = (0, types_1.entitlementError)(override.entitlement);
|
|
178
203
|
if (invalid)
|
|
@@ -135,6 +135,14 @@ export declare const DEFAULT_RECIPES: ({
|
|
|
135
135
|
})[];
|
|
136
136
|
export declare class StiggAgentSDK {
|
|
137
137
|
readonly config: ResolvedConfig;
|
|
138
|
+
/**
|
|
139
|
+
* What this object says about itself when something logs it.
|
|
140
|
+
*
|
|
141
|
+
* Without these, `console.log(stigg)` walks the whole object - the config, the engine once it
|
|
142
|
+
* has started, and the vendor clients each of those holds - and any of them may carry a key.
|
|
143
|
+
* The redacted view names what is worth seeing and stops there.
|
|
144
|
+
*/
|
|
145
|
+
toJSON(): Record<string, unknown>;
|
|
138
146
|
private readonly clients;
|
|
139
147
|
/** Every session this SDK handed out, so `flush()` can drain all of them at once. */
|
|
140
148
|
private readonly sessions;
|
package/dist/stigg-agent-sdk.js
CHANGED
|
@@ -155,6 +155,19 @@ function toPlacement(input) {
|
|
|
155
155
|
}
|
|
156
156
|
class StiggAgentSDK {
|
|
157
157
|
config;
|
|
158
|
+
/**
|
|
159
|
+
* What this object says about itself when something logs it.
|
|
160
|
+
*
|
|
161
|
+
* Without these, `console.log(stigg)` walks the whole object - the config, the engine once it
|
|
162
|
+
* has started, and the vendor clients each of those holds - and any of them may carry a key.
|
|
163
|
+
* The redacted view names what is worth seeing and stops there.
|
|
164
|
+
*/
|
|
165
|
+
toJSON() {
|
|
166
|
+
return { baseUrl: this.config.baseUrl, apiKey: '[redacted]', started: this.engineState !== undefined };
|
|
167
|
+
}
|
|
168
|
+
[Symbol.for('nodejs.util.inspect.custom')]() {
|
|
169
|
+
return this.toJSON();
|
|
170
|
+
}
|
|
158
171
|
clients;
|
|
159
172
|
/** Every session this SDK handed out, so `flush()` can drain all of them at once. */
|
|
160
173
|
sessions = new Set();
|
package/dist/types.d.ts
CHANGED
|
@@ -334,7 +334,18 @@ export type ClientOptions = {
|
|
|
334
334
|
/** Everything this client reports back while it works. See {@link SessionHooks}. */
|
|
335
335
|
hooks?: SessionHooks;
|
|
336
336
|
/** The default context for every call through this client. Any call may override it. */
|
|
337
|
-
stiggContext?:
|
|
337
|
+
stiggContext?: ClientContext;
|
|
338
|
+
};
|
|
339
|
+
/**
|
|
340
|
+
* A client's default context: everything in {@link StiggContext} except `idempotencyKey`.
|
|
341
|
+
*
|
|
342
|
+
* That one deduplicates a single report, so a value shared by every call through the client would
|
|
343
|
+
* bill the first and discard the rest. The runtime refuses it either way - a JavaScript caller
|
|
344
|
+
* reaches `wrap` without passing through this type - but a TypeScript caller should hear it from
|
|
345
|
+
* the compiler rather than from production.
|
|
346
|
+
*/
|
|
347
|
+
export type ClientContext = Omit<StiggContext, 'idempotencyKey'> & {
|
|
348
|
+
idempotencyKey?: never;
|
|
338
349
|
};
|
|
339
350
|
/**
|
|
340
351
|
* `register` - giving an agent an identity, a budget, and a place in the tree.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stigg/agent-sdk-js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Stigg agent SDK (JS) - run-lifecycle governance for agentic stacks, with examples per layer",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"typings": "dist/index.d.ts",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"node": ">=22"
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
|
-
"@stigg/node-server-sdk": "6.
|
|
67
|
+
"@stigg/node-server-sdk": "6.2.0",
|
|
68
68
|
"@stigg/typescript": "0.1.0-beta.55",
|
|
69
69
|
"zod": "^4.6.1"
|
|
70
70
|
},
|