@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.
@@ -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 || typeof stream[Symbol.asyncIterator] !== 'function')
84
+ if (!stream)
85
85
  return;
86
- const iterate = stream[Symbol.asyncIterator].bind(stream);
87
- stream[Symbol.asyncIterator] = async function* gatedIterator() {
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 iterate()) {
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
- if (iterate) {
112
- stream[Symbol.asyncIterator] = async function* metered() {
113
- const it = source();
114
- // Whether the source ran itself out, as opposed to the consumer walking away from it. Only
115
- // the second case owes it a `return()` - see the `finally`.
116
- let exhausted = false;
117
- try {
118
- while (true) {
119
- const { value, done } = await it.next();
120
- if (done) {
121
- exhausted = true;
122
- return;
123
- }
124
- observe(value);
125
- yield value;
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
- finally {
129
- // Closes the vendor's stream when the consumer broke out early, which is what `for await`
130
- // did on the way out. Without it an abandoned stream holds its connection open.
131
- //
132
- // Only then, though. The iteration protocol closes an iterator on *abrupt* completion and
133
- // deliberately not when it ran itself out, so a source that already finished is never
134
- // asked to close - driving `next()` by hand means saying that explicitly rather than
135
- // inheriting it. Calling `return()` on a spent iterator is a no-op for a plain generator,
136
- // but a vendor is free to implement it as "abort the request", and aborting a stream that
137
- // has already delivered its last chunk is a cancellation nobody asked for.
138
- if (!exhausted) {
139
- try {
140
- await it.return?.();
141
- }
142
- catch {
143
- // A source that objects to being closed must not mask the caller's own exit path.
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
- return {
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
  }
@@ -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
- customerId = override.customerId?.trim() || base.customerId;
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;
@@ -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?: 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.2.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.1.0",
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
  },