@stigg/agent-sdk-js 0.5.0 β†’ 0.6.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
@@ -73,6 +73,54 @@ try {
73
73
  the shortest path from nothing to working metering is a currency and a `customerId` β€” no catalog
74
74
  modelling first.
75
75
 
76
+ ## πŸ•ΈοΈ Governing an agent graph
77
+
78
+ A wrapper governs one client. A LangChain or LangGraph graph runs many agents and tools, so it
79
+ gets one handler instead, passed on `invoke` β€” LangChain hands it to every model and tool call
80
+ beneath, sub-agents included. Each agent is checked and reports its usage as itself. It imports
81
+ `@langchain/core`, an optional peer, so it uses the copy your application already has.
82
+
83
+ ```ts
84
+ import { StiggAgentSDK } from '@stigg/agent-sdk-js';
85
+ import { StiggLangChainCallbackHandler } from '@stigg/agent-sdk-js/langchain';
86
+
87
+ const stigg = new StiggAgentSDK();
88
+ const credits = { currencyId: 'credits' };
89
+
90
+ // Once per customer: each agent an entity with its own budget - what `agentId` below names.
91
+ for (const agentId of ['agent-researcher', 'agent-writer']) {
92
+ await stigg.register({ customerId: 'customer-acme', agentId, entityTypeId: 'agent', budget: { entitlement: credits, limit: 100 } });
93
+ }
94
+
95
+ const stiggHandler = new StiggLangChainCallbackHandler({
96
+ stigg,
97
+ stiggContext: { attributionKey: 'agentId', entitlement: credits }, // the whole graph
98
+ agents: {
99
+ // by the name LangChain knows the agent or node by
100
+ researcher: { stiggContext: { agentId: 'agent-researcher' }, tools: { web_search: credits } },
101
+ writer: { stiggContext: { agentId: 'agent-writer' } },
102
+ },
103
+ });
104
+
105
+ await graph.invoke(input, {
106
+ callbacks: [stiggHandler],
107
+ metadata: { stiggContext: { customerId: 'customer-acme' } }, // per run
108
+ });
109
+ ```
110
+
111
+ Both take the same `stiggContext`, merged the same way β€” only where it attaches differs:
112
+
113
+ | | Wrapper | Agent graph |
114
+ | --- | --- | --- |
115
+ | Attaches to | one vendor client | the graph's `invoke` |
116
+ | The whole thing | `wrap(client, { stiggContext })` | `new StiggLangChainCallbackHandler({ stiggContext })` |
117
+ | Per agent | a wrapped client per agent | `agents[name].stiggContext` |
118
+ | Per request | `create({ ..., stiggContext })` | `invoke(input, { metadata: { stiggContext } })` |
119
+ | Tools | not seen | `tools`, checked and metered per call |
120
+ | A refusal | the call throws | the step throws, and the graph decides β€” a refused sub-agent reads to its lead as a failed tool call |
121
+
122
+ The [LangChain and LangGraph guide](docs/langchain.md) has every configuration.
123
+
76
124
  ## 🧭 How it works
77
125
 
78
126
  Three things happen around a call you already make:
@@ -1,12 +1,16 @@
1
- import { type ResolvedContext } from '../../../core/context';
1
+ import { type ResolvedContext, type StiggContext } from '../../../core/context';
2
2
  import type { GovernanceSession } from '../../../core/session';
3
3
  import { type Entitlement, type Measurement, type MiddlewareOptions, type MiddlewareTools, type ToolUsageMapper } from '../../../types';
4
4
  /** Who a step is for, as the framework stated it on that step. */
5
5
  export type StepAttribution = {
6
6
  /** The agent that took the step, by the framework's own name for it. */
7
7
  agent?: string;
8
- /** The customer the run is for, when the run named one. */
9
- customerId?: string;
8
+ /**
9
+ * The `stiggContext`s the run stated on the way down to this step, outermost first - the run's,
10
+ * then any a node set for its own calls. Each is applied over the agent's context the way `wrap`
11
+ * applies a call's: the customer, dimensions, and anything else a call can override.
12
+ */
13
+ stiggContexts?: readonly StiggContext[];
10
14
  };
11
15
  /** A tool call the model issued in its reply: the id the tool step will refer back to, and what it named. */
12
16
  export type IssuedToolCall = {
@@ -34,14 +38,14 @@ export type AgentMiddleware = {
34
38
  /**
35
39
  * After a model call: its measurement is ingested, or the missing one reported. Never throws.
36
40
  * `issued` is the tool calls the reply asked for, so a tool step that arrives with only its call
37
- * id is named, and billed to the agent that called it.
41
+ * id is named, and reported for the agent that called it.
38
42
  */
39
43
  endModel(runId: string, measurement: Measurement | undefined, issued?: readonly IssuedToolCall[]): Promise<void>;
40
44
  /** Before a tool runs: checked when the tool is a paid one, raising when refused. */
41
45
  startTool(runId: string, tool: ToolStep, attribution: StepAttribution): Promise<void>;
42
46
  /** After a tool succeeded: what its `usage` makes of the call, or one unit. Never throws. */
43
- endTool(runId: string, output?: unknown): Promise<void>;
44
- /** A step that failed: forgotten, and billed nothing - it did not deliver what it charges for. */
47
+ endTool(runId: string, output?: unknown, name?: string): Promise<void>;
48
+ /** A step that failed: forgotten, and reports nothing - it did not deliver what it charges for. */
45
49
  abandon(runId: string): void;
46
50
  };
47
51
  /** How one tool is governed, its shorthand expanded. */
@@ -75,6 +79,13 @@ export type AgentMiddlewareConfig = {
75
79
  agents: ReadonlyMap<string, ResolvedAgent>;
76
80
  /** Told once per agent that was not listed, so the caller can say so. */
77
81
  onUnlisted(agent: string): void;
82
+ /** Told once per tool whose usage was reported but not checked, because its name came only with its result. */
83
+ onUncheckedTool(tool: string): void;
84
+ /**
85
+ * Told once per kind of tool that ran with no name at all - called directly, so nothing named it
86
+ * and it could not be priced. Possibly a free tool, so a warning rather than a metering failure.
87
+ */
88
+ onUnnamedTool(kind: string | undefined): void;
78
89
  /** Where the caller puts the customer - quoted when a step has none. */
79
90
  missingCustomer: string;
80
91
  };
@@ -11,7 +11,7 @@ exports.createAgentMiddleware = createAgentMiddleware;
11
11
  * knows one vendor; here the middleware governs a graph's steps and a framework binding (the
12
12
  * LangChain callback handler, or a harness of your own) knows how its framework announces them.
13
13
  *
14
- * Every step is checked and billed as the agent that took it, so one agent running dry refuses
14
+ * Every step is checked and reported as the agent that took it, so one agent running dry refuses
15
15
  * that agent's steps and nothing else. A refusal is the SDK's own `AccessDeniedError`, thrown from
16
16
  * inside the step - the frameworks worth binding to offer hooks that observe a step, not ones that
17
17
  * replace its result - and what it means for the run is the graph's to decide. Nothing else throws
@@ -19,10 +19,11 @@ exports.createAgentMiddleware = createAgentMiddleware;
19
19
  * `checkErrorAction`.
20
20
  *
21
21
  * Steps are keyed by the run id the framework gives each one, because the end of a step usually
22
- * carries nothing else: what the start resolved is what the end bills.
22
+ * carries nothing else: what the start resolved is what the end reports.
23
23
  */
24
24
  const errors_1 = require("../../../errors");
25
25
  const context_1 = require("../../../core/context");
26
+ const hooks_1 = require("../../../core/hooks");
26
27
  const types_1 = require("../../../types");
27
28
  /** The key that stands for every tool a configuration does not name. */
28
29
  exports.ALL_TOOLS = '*';
@@ -68,7 +69,7 @@ function resolveAgents(shared, { tools, agents }) {
68
69
  // into the first. The shared context is refused the same way, in `resolveContext`.
69
70
  if (agent.stiggContext?.idempotencyKey !== undefined) {
70
71
  throw new Error(`stigg.middleware's agents.${name}.stiggContext cannot set \`idempotencyKey\` - one key shared ` +
71
- 'by every step that agent takes would bill the first and discard the rest. Each step is keyed by its own id.');
72
+ 'by every step that agent takes would report the first and discard the rest. Each step is keyed by its own id.');
72
73
  }
73
74
  resolved.set(name, {
74
75
  context: agent.stiggContext ? (0, context_1.overrideContext)(shared, agent.stiggContext) : shared,
@@ -96,6 +97,13 @@ function toolFor(tools, name) {
96
97
  */
97
98
  const ISSUED_CALLS_LIMIT = 1_000;
98
99
  const STEPS_LIMIT = 10_000;
100
+ /**
101
+ * What deduplicates a tool step's report: the call id the model issued, plus what it reports
102
+ * against - one tool call is one report, and two entitlements on one call must not collide.
103
+ */
104
+ function toolKey(callId, context, name) {
105
+ return callId ? `${callId}:${context.checkedId ?? name}` : undefined;
106
+ }
99
107
  /** Adds to a map that drops its oldest entry past `limit`, and hands back what it dropped. */
100
108
  function remember(map, key, value, limit) {
101
109
  map.set(key, value);
@@ -110,22 +118,43 @@ function createAgentMiddleware(session, config) {
110
118
  const steps = new Map();
111
119
  /** The tool calls models issued, by call id - kept for re-runs, until the cap drops them. */
112
120
  const issuedCalls = new Map();
121
+ /** Tools that started without a name, by run id - until they end. */
122
+ const unnamedTools = new Map();
123
+ const warnedUnchecked = new Set();
124
+ const warnedUnnamed = new Set();
113
125
  const warnedUnlisted = new Set();
114
126
  /**
115
127
  * The agent a step belongs to. One the configuration does not list - or a step attributed to no
116
- * agent - resolves to the shared context, so nothing runs unbilled for having been left out.
128
+ * agent - resolves to the shared context, so nothing runs unmetered for having been left out.
117
129
  */
118
130
  const agentFor = (agent) => {
119
131
  const own = agent === undefined ? undefined : config.agents.get(agent);
120
- if (!own && agent !== undefined && config.agents.size > 0 && !warnedUnlisted.has(agent)) {
132
+ // Capped: the names come from the caller's own runs, so an unbounded set is theirs to grow.
133
+ if (!own &&
134
+ agent !== undefined &&
135
+ config.agents.size > 0 &&
136
+ !warnedUnlisted.has(agent) &&
137
+ warnedUnlisted.size < 100) {
121
138
  warnedUnlisted.add(agent);
122
- config.onUnlisted(agent);
139
+ warn(() => config.onUnlisted(agent));
123
140
  }
124
141
  return own ?? config.shared;
125
142
  };
126
- /** The context for one step. The customer is required: an empty one would be billed to nobody. */
127
- const contextFor = (base, { agent, customerId }) => {
128
- const context = customerId ? (0, context_1.overrideContext)(base, { customerId }) : base;
143
+ /** The context for one step. The customer is required: an empty one would report usage for nobody. */
144
+ const contextFor = (base, attribution) => {
145
+ const { agent, stiggContexts = [] } = attribution;
146
+ // A harness from 0.5 still naming the customer here would otherwise be told it named none.
147
+ if ('customerId' in attribution) {
148
+ throw new Error('A step names its customer in `stiggContexts: [{ customerId }]` - `StepAttribution.customerId` was ' +
149
+ 'removed in 0.6.');
150
+ }
151
+ // A run spans many steps, so a key of its own would dedupe them all into the first - refused
152
+ // as the shared and the agents' contexts refuse it. Each step is keyed by its own id.
153
+ if (stiggContexts.some((layer) => layer?.idempotencyKey !== undefined)) {
154
+ throw new Error("A run's stiggContext cannot set `idempotencyKey` - one key shared by every step beneath it would " +
155
+ 'report the first and discard the rest. Each step is keyed by its own id.');
156
+ }
157
+ const context = stiggContexts.reduce((resolved, override) => (0, context_1.overrideContext)(resolved, override), base);
129
158
  if (!context.customerId)
130
159
  throw (0, context_1.noCustomer)(config.missingCustomer);
131
160
  return agent === undefined ? context : { ...context, agent };
@@ -138,26 +167,39 @@ function createAgentMiddleware(session, config) {
138
167
  if (!decision.hasAccess)
139
168
  throw new errors_1.AccessDeniedError(decision);
140
169
  };
141
- /** A step in flight. One dropped to make room is never billed, so that is said, not silent. */
170
+ /** A step in flight. One dropped to make room is never reported, so that is said, not silent. */
142
171
  const rememberStep = (runId, step) => {
143
172
  const dropped = remember(steps, runId, step, STEPS_LIMIT);
144
- if (dropped) {
145
- session.failedToMeasure(dropped.context, new Error(`a step was still in flight after ${STEPS_LIMIT} newer ones started, so it was dropped unbilled`));
146
- }
173
+ if (dropped)
174
+ reportDropped(dropped.context, 'a step');
175
+ };
176
+ /** An unnamed tool held until its result names it - and said, if it is dropped to make room. */
177
+ const rememberUnnamed = (runId, tool) => {
178
+ const dropped = remember(unnamedTools, runId, tool, STEPS_LIMIT);
179
+ if (dropped)
180
+ reportDropped(dropped.context, 'a tool with no name');
147
181
  };
182
+ const reportDropped = (context, what) => session.failedToMeasure(context, new Error(`${what} was still in flight after ${STEPS_LIMIT} newer ones started, so it was dropped unreported`));
183
+ /** The application's warnings are its own code: one that throws must not fail a step. */
184
+ const warn = (say) => (0, hooks_1.safely)(say, () => undefined);
148
185
  /** The tool calls a reply asked for, kept until each tool has run. */
149
186
  const rememberIssuedCalls = (issued, agent) => {
150
187
  for (const call of issued)
151
188
  remember(issuedCalls, call.id, { name: call.name, agent }, ISSUED_CALLS_LIMIT);
152
189
  };
153
- /** A tool step's context: priced by the tool's own entitlement, when it has one, and labelled with the tool. */
190
+ /**
191
+ * A tool step's context: priced by the tool's own entitlement, when it has one, and labelled with
192
+ * the tool. The tool goes last - a run's `stiggContext` naming another entitlement re-prices its
193
+ * model calls, not the tools it set a price on.
194
+ */
154
195
  const toolContext = (agent, tool, name, attribution) => {
155
196
  const eventName = { name: `tool.${name}` };
156
- const priced = (0, context_1.overrideContext)(agent.context, tool.entitlement ? { entitlement: tool.entitlement, eventName } : { eventName });
157
- return { ...contextFor(priced, attribution), tool: name };
197
+ const step = contextFor(agent.context, attribution);
198
+ const priced = (0, context_1.overrideContext)(step, tool.entitlement ? { entitlement: tool.entitlement, eventName } : { eventName });
199
+ return { ...priced, agent: step.agent, tool: name };
158
200
  };
159
- /** Bills a tool step that ended: its `usage`, or one unit. A bad `usage` is a metering failure. */
160
- const billTool = async ({ context, idempotencyKey, tool }, output) => {
201
+ /** Reports what a tool step that ended used: its `usage`, or one unit. A bad `usage` is a metering failure. */
202
+ const ingestTool = async ({ context, idempotencyKey, tool }, output) => {
161
203
  const options = { idempotencyKey };
162
204
  if (!tool?.usage) {
163
205
  await session.ingest(context, 1, options);
@@ -174,6 +216,40 @@ function createAgentMiddleware(session, config) {
174
216
  session.failedToMeasure(context, err);
175
217
  }
176
218
  };
219
+ /**
220
+ * The step an unnamed tool would have started, now that its result names it - or nothing to
221
+ * report, when it has no name or the name is not governed. It ran unchecked, which is said once
222
+ * per tool: only `'*'` can gate a tool whose name is not known when it starts.
223
+ */
224
+ const stepFromResult = (runId, name) => {
225
+ const unnamed = unnamedTools.get(runId);
226
+ if (!unnamed)
227
+ return undefined;
228
+ if (name === undefined) {
229
+ // Called directly - no tool call, no ToolMessage - so nothing ever named it. It may well be a
230
+ // free tool, so this is said once per kind of tool rather than raised as a metering failure.
231
+ const kind = unnamed.kind ?? '(unknown)';
232
+ if (!warnedUnnamed.has(kind) && warnedUnnamed.size < 100) {
233
+ warnedUnnamed.add(kind);
234
+ warn(() => config.onUnnamedTool(unnamed.kind));
235
+ }
236
+ return undefined;
237
+ }
238
+ const agent = agentFor(unnamed.owner.agent);
239
+ const tool = toolFor(agent.tools, name);
240
+ if (!tool)
241
+ return undefined;
242
+ if (tool.entitlement && !warnedUnchecked.has(name)) {
243
+ warnedUnchecked.add(name);
244
+ warn(() => config.onUncheckedTool(name));
245
+ }
246
+ const context = toolContext(agent, tool, name, unnamed.owner);
247
+ return {
248
+ context,
249
+ tool: { name, input: unnamed.input, usage: tool.usage, callId: unnamed.callId },
250
+ idempotencyKey: toolKey(unnamed.callId, context, name),
251
+ };
252
+ };
177
253
  return {
178
254
  async startModel(runId, attribution, eventName) {
179
255
  const resolved = contextFor(agentFor(attribution.agent).context, attribution);
@@ -197,7 +273,7 @@ function createAgentMiddleware(session, config) {
197
273
  },
198
274
  async startTool(runId, { name, callId, kind, input }, attribution) {
199
275
  // Never taken, only looked up - the cap drops old ones: a tools node that runs again, before
200
- // or after the call succeeded, is the same call, owned by the same agent, billed under one key.
276
+ // or after the call succeeded, is the same call, owned by the same agent, reported under one key.
201
277
  const issued = callId === undefined ? undefined : issuedCalls.get(callId);
202
278
  const named = name ?? issued?.name;
203
279
  const toolName = named ?? kind;
@@ -205,26 +281,35 @@ function createAgentMiddleware(session, config) {
205
281
  const owner = { ...attribution, agent: issued?.agent ?? attribution.agent };
206
282
  const agent = agentFor(owner.agent);
207
283
  const tool = toolName ? toolFor(agent.tools, named) : undefined;
208
- if (!toolName || !tool)
284
+ if (!toolName || !tool) {
285
+ // Nothing to check it against yet - but its result will name it, and a priced tool must
286
+ // not run free because its name was not known when it started.
287
+ // Only when a name could match - and with its context resolved now, so a run with no
288
+ // customer is refused before the tool runs, as a named tool is.
289
+ if (named === undefined && Object.values(agent.tools).some(Boolean)) {
290
+ rememberUnnamed(runId, { owner, context: contextFor(agent.context, owner), input, callId, kind });
291
+ }
209
292
  return;
293
+ }
210
294
  const context = toolContext(agent, tool, toolName, owner);
211
295
  if (tool.entitlement)
212
296
  await check(context);
213
297
  rememberStep(runId, {
214
298
  context,
215
299
  tool: { name: toolName, input, usage: tool.usage, callId },
216
- // One tool call is one report, and two entitlements on one call must not collide.
217
- idempotencyKey: callId ? `${callId}:${context.checkedId ?? toolName}` : undefined,
300
+ idempotencyKey: toolKey(callId, context, toolName),
218
301
  });
219
302
  },
220
- async endTool(runId, output) {
221
- const step = steps.get(runId);
222
- if (!step)
223
- return;
303
+ async endTool(runId, output, name) {
304
+ // Started governed, or started unnamed and governed by the name its result carries.
305
+ const step = steps.get(runId) ?? stepFromResult(runId, name);
224
306
  steps.delete(runId);
225
- await billTool(step, output);
307
+ unnamedTools.delete(runId);
308
+ if (step)
309
+ await ingestTool(step, output);
226
310
  },
227
311
  abandon(runId) {
312
+ unnamedTools.delete(runId);
228
313
  steps.delete(runId);
229
314
  },
230
315
  };
@@ -2,18 +2,24 @@
2
2
  * The LangChain and LangGraph binding: a callback handler over an agent graph's middleware.
3
3
  *
4
4
  * const stiggHandler = new StiggLangChainCallbackHandler({ stigg, stiggContext, agents });
5
- * await agent.invoke(input, { callbacks: [stiggHandler], metadata: { stiggCustomerId } });
5
+ * await agent.invoke(input, { callbacks: [stiggHandler], metadata: { stiggContext: { customerId } } });
6
6
  *
7
- * Everything here is LangChain's protocol - which hook a step starts on, where the agent's name,
8
- * the customer and the usage are. Whether a step may run and what it is worth is the middleware's,
9
- * and the handler keeps no state of its own. LangChain hands a callback to every run beneath the
10
- * `invoke`, sub-agents and LangGraph nodes included, so one handler governs the whole graph.
7
+ * Everything here is LangChain's protocol - which hook a step starts on, where the run's
8
+ * `stiggContext` and the usage are, which agent a step belongs to. Whether a step may run and what it is worth is the
9
+ * middleware's. LangChain hands a callback to every run beneath the `invoke`, sub-agents and
10
+ * LangGraph nodes included, so one handler governs the whole graph.
11
+ *
12
+ * A step belongs to the nearest run above it named for a configured agent: a `createAgent` or
13
+ * `createReactAgent` by its `name`, a hand-built graph's node by its own. That is LangChain's run
14
+ * tree, not metadata - `lc_agent_name` in metadata is inherited by an unnamed sub-agent, carried
15
+ * into a sub-agent by a tool that passes its own config, and settable by whoever sends the run's
16
+ * metadata, so it would charge the wrong agent or let a client pick whose budget pays.
11
17
  *
12
18
  * It blocks by default. LangChain swallows a handler's error unless `raiseError` is set, and runs
13
19
  * an async handler in the background unless `awaitHandlers` is set too, so both are set - through
14
20
  * the constructor, because `awaitHandlers` is derived inside `super()`. And LangChain copies a
15
21
  * handler (`new this.constructor(this)`) on its way into a nested run, so a copy shares the
16
- * original's middleware rather than billing a sub-agent to a fresh session.
22
+ * original's middleware and run tree rather than billing a sub-agent to a fresh session.
17
23
  *
18
24
  * The one file in `src/` that imports LangChain, reached only through the
19
25
  * `@stigg/agent-sdk-js/langchain` entry.
@@ -22,14 +28,24 @@ import { BaseCallbackHandler } from '@langchain/core/callbacks/base';
22
28
  import type { StiggAgentSDK } from '../../../stigg-agent-sdk';
23
29
  import type { GovernedAgentMiddleware, MiddlewareOptions } from '../../../types';
24
30
  /**
25
- * The metadata key a caller names the customer under, on `invoke(input, { metadata })`. It wins
26
- * over the handler's default, so set it server-side - on LangGraph Platform the client sends
27
- * `metadata`, so set it in your auth handler, never from the request.
31
+ * The metadata key a run states its `stiggContext` under, on `invoke(input, { metadata })` - the
32
+ * same override `wrap` takes per call, applied over the handler's and the agent's. On `invoke` it
33
+ * names only the customer and `correlationId` (`RUN_FIELDS`); a node's own config may state more.
34
+ * On LangGraph Platform the client sends `metadata`, so set the customer in your auth handler,
35
+ * never from the request.
28
36
  */
37
+ export declare const CONTEXT_METADATA_KEY = "stiggContext";
38
+ /** @deprecated The customer alone, from before a run took a whole `stiggContext`. Read as `stiggContext.customerId`. */
29
39
  export declare const CUSTOMER_METADATA_KEY = "stiggCustomerId";
30
40
  export type StiggLangChainCallbackOptions = MiddlewareOptions & {
31
41
  /** The SDK this handler governs through - one per environment, shared with everything else. */
32
42
  stigg: StiggAgentSDK;
43
+ /**
44
+ * The agent the top-level run is - one of `agents`. Set it when the graph's entry point is itself
45
+ * an agent: the caller names the top-level run (`invoke(input, { runName })`), so without this its
46
+ * name would let them pick whose budget pays, or step outside any agent's.
47
+ */
48
+ rootAgent?: string;
33
49
  };
34
50
  export declare class StiggLangChainCallbackHandler extends BaseCallbackHandler {
35
51
  name: string;
@@ -39,12 +55,30 @@ export declare class StiggLangChainCallbackHandler extends BaseCallbackHandler {
39
55
  * given (`stigg.flush()`), and reported through the `hooks` it was configured with.
40
56
  */
41
57
  protected readonly middleware: GovernedAgentMiddleware;
58
+ private readonly runs;
42
59
  constructor(options: StiggLangChainCallbackOptions | StiggLangChainCallbackHandler);
43
- handleChatModelStart(_llm: unknown, _messages: unknown, runId: string, _parentRunId?: string, _extraParams?: unknown, _tags?: string[], metadata?: Record<string, unknown>): Promise<void>;
44
- handleLLMStart(_llm: unknown, _prompts: unknown, runId: string, _parentRunId?: string, _extraParams?: unknown, _tags?: string[], metadata?: Record<string, unknown>): Promise<void>;
60
+ handleChainStart(...args: Parameters<NonNullable<BaseCallbackHandler['handleChainStart']>>): void;
61
+ handleChainEnd(_outputs: unknown, runId: string): void;
62
+ handleChainError(_err: unknown, runId: string): void;
63
+ handleChatModelStart(_llm: unknown, _messages: unknown, runId: string, parentRunId?: string, _extraParams?: unknown, _tags?: string[], metadata?: Record<string, unknown>): Promise<void>;
64
+ handleLLMStart(_llm: unknown, _prompts: unknown, runId: string, parentRunId?: string, _extraParams?: unknown, _tags?: string[], metadata?: Record<string, unknown>): Promise<void>;
45
65
  handleLLMEnd(output: unknown, runId: string): Promise<void>;
46
66
  handleLLMError(_err: unknown, runId: string): void;
47
- handleToolStart(tool: unknown, input: string, runId: string, _parentRunId?: string, _tags?: string[], metadata?: Record<string, unknown>, runName?: string, toolCallId?: string): Promise<void>;
67
+ handleToolStart(tool: unknown, input: string, runId: string, parentRunId?: string, _tags?: string[], metadata?: Record<string, unknown>, runName?: string, toolCallId?: string): Promise<void>;
48
68
  handleToolEnd(output: unknown, runId: string): Promise<void>;
49
69
  handleToolError(_err: unknown, runId: string): void;
70
+ /**
71
+ * Who a step is for: the configured agent above it in the run tree, and the `stiggContext`s the
72
+ * run stated on the way down. With no agent above it, a name from metadata is passed on only as a
73
+ * label for the "not listed" warning - never a configured agent's name, which the tree alone can
74
+ * confer.
75
+ */
76
+ private attributionOf;
77
+ /**
78
+ * The `stiggContext` a run's metadata states, with the customer the deprecated key names. That key
79
+ * is what a server's auth handler was told to set, so it is the one trusted: a `stiggContext`
80
+ * naming another customer beside it is refused rather than charged to someone else. A run with no
81
+ * parent - `invoke`'s own metadata - states only `RUN_FIELDS`, with the deprecated key or without.
82
+ */
83
+ private stiggContextOf;
50
84
  }
@@ -1,43 +1,142 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.StiggLangChainCallbackHandler = exports.CUSTOMER_METADATA_KEY = void 0;
3
+ exports.StiggLangChainCallbackHandler = exports.CUSTOMER_METADATA_KEY = exports.CONTEXT_METADATA_KEY = void 0;
4
4
  /**
5
5
  * The LangChain and LangGraph binding: a callback handler over an agent graph's middleware.
6
6
  *
7
7
  * const stiggHandler = new StiggLangChainCallbackHandler({ stigg, stiggContext, agents });
8
- * await agent.invoke(input, { callbacks: [stiggHandler], metadata: { stiggCustomerId } });
8
+ * await agent.invoke(input, { callbacks: [stiggHandler], metadata: { stiggContext: { customerId } } });
9
9
  *
10
- * Everything here is LangChain's protocol - which hook a step starts on, where the agent's name,
11
- * the customer and the usage are. Whether a step may run and what it is worth is the middleware's,
12
- * and the handler keeps no state of its own. LangChain hands a callback to every run beneath the
13
- * `invoke`, sub-agents and LangGraph nodes included, so one handler governs the whole graph.
10
+ * Everything here is LangChain's protocol - which hook a step starts on, where the run's
11
+ * `stiggContext` and the usage are, which agent a step belongs to. Whether a step may run and what it is worth is the
12
+ * middleware's. LangChain hands a callback to every run beneath the `invoke`, sub-agents and
13
+ * LangGraph nodes included, so one handler governs the whole graph.
14
+ *
15
+ * A step belongs to the nearest run above it named for a configured agent: a `createAgent` or
16
+ * `createReactAgent` by its `name`, a hand-built graph's node by its own. That is LangChain's run
17
+ * tree, not metadata - `lc_agent_name` in metadata is inherited by an unnamed sub-agent, carried
18
+ * into a sub-agent by a tool that passes its own config, and settable by whoever sends the run's
19
+ * metadata, so it would charge the wrong agent or let a client pick whose budget pays.
14
20
  *
15
21
  * It blocks by default. LangChain swallows a handler's error unless `raiseError` is set, and runs
16
22
  * an async handler in the background unless `awaitHandlers` is set too, so both are set - through
17
23
  * the constructor, because `awaitHandlers` is derived inside `super()`. And LangChain copies a
18
24
  * handler (`new this.constructor(this)`) on its way into a nested run, so a copy shares the
19
- * original's middleware rather than billing a sub-agent to a fresh session.
25
+ * original's middleware and run tree rather than billing a sub-agent to a fresh session.
20
26
  *
21
27
  * The one file in `src/` that imports LangChain, reached only through the
22
28
  * `@stigg/agent-sdk-js/langchain` entry.
23
29
  */
24
30
  const base_1 = require("@langchain/core/callbacks/base");
31
+ const hooks_1 = require("../../../core/hooks");
25
32
  const token_usage_1 = require("../../shared/token-usage");
26
33
  /**
27
- * The metadata key a caller names the customer under, on `invoke(input, { metadata })`. It wins
28
- * over the handler's default, so set it server-side - on LangGraph Platform the client sends
29
- * `metadata`, so set it in your auth handler, never from the request.
34
+ * The metadata key a run states its `stiggContext` under, on `invoke(input, { metadata })` - the
35
+ * same override `wrap` takes per call, applied over the handler's and the agent's. On `invoke` it
36
+ * names only the customer and `correlationId` (`RUN_FIELDS`); a node's own config may state more.
37
+ * On LangGraph Platform the client sends `metadata`, so set the customer in your auth handler,
38
+ * never from the request.
39
+ */
40
+ exports.CONTEXT_METADATA_KEY = 'stiggContext';
41
+ /**
42
+ * What the `stiggContext` of a run with no parent - `invoke`'s own metadata - may state. On LangGraph
43
+ * Platform the request's client writes that metadata, so it names who the run is for and nothing that
44
+ * prices it or says which agent it is. The rest belongs on the handler, under `agents`, or in a
45
+ * node's own config.
30
46
  */
47
+ const RUN_FIELDS = new Set(['customerId', 'correlationId']);
48
+ /** @deprecated The customer alone, from before a run took a whole `stiggContext`. Read as `stiggContext.customerId`. */
31
49
  exports.CUSTOMER_METADATA_KEY = 'stiggCustomerId';
32
- /** What `createAgent({ name })` stamps on every run of that agent. */
50
+ /**
51
+ * What `createAgent({ name })` and LangGraph stamp in metadata. Never what a step is reported as -
52
+ * see the top of this file - only the label an unlisted agent is reported under.
53
+ */
33
54
  const AGENT_METADATA_KEY = 'lc_agent_name';
34
- /** What LangGraph stamps on every step: the graph node it runs in - the agent, in a hand-built graph. */
35
55
  const NODE_METADATA_KEY = 'langgraph_node';
56
+ /** How many runs can be open at once before the oldest is forgotten - a run whose end never came. */
57
+ const OPEN_RUNS_LIMIT = 10_000;
36
58
  /** The counters LangChain's `usage_metadata` states at the top level, reported under its own names. */
37
59
  const LANGCHAIN_METRICS = ['input_tokens', 'output_tokens', 'total_tokens'];
38
60
  /** Where a caller puts the customer, for the refusal a step with none raises. */
39
- const MISSING_CUSTOMER = `Name it on every run as \`invoke(input, { metadata: { ${exports.CUSTOMER_METADATA_KEY}: customerId } })\`, ` +
61
+ const MISSING_CUSTOMER = `Name it on every run as \`invoke(input, { metadata: { ${exports.CONTEXT_METADATA_KEY}: { customerId } } })\`, ` +
40
62
  'or give the handler a default in its `stiggContext: { customerId }`.';
63
+ /**
64
+ * The runs LangChain has open, each with its name and parent, so a step can be traced up to the
65
+ * agent it belongs to. Shared by a handler and every copy LangChain makes of it.
66
+ */
67
+ class RunTree {
68
+ agents;
69
+ rootAgent;
70
+ warn;
71
+ open = new Map();
72
+ warnedFull = false;
73
+ warned = new Set();
74
+ /**
75
+ * `rootAgent`, when set, is what a top-level run is, whatever it was named: the caller names that
76
+ * run (`invoke(input, { runName })`), so its name alone would let them pick whose budget pays.
77
+ * `warn` is the SDK's logger, for what is said once.
78
+ */
79
+ constructor(agents, rootAgent, warn) {
80
+ this.agents = agents;
81
+ this.rootAgent = rootAgent;
82
+ this.warn = warn;
83
+ }
84
+ start(runId, parent, name, stiggContext) {
85
+ this.open.set(runId, {
86
+ name: parent === undefined && this.rootAgent ? this.rootAgent : name,
87
+ parent,
88
+ stiggContext,
89
+ });
90
+ if (this.open.size <= OPEN_RUNS_LIMIT)
91
+ return;
92
+ // A forgotten run breaks the walk for the steps beneath it: they fall back to the shared
93
+ // context - still checked and reported, but not as their agent - so that is said.
94
+ this.open.delete(this.open.keys().next().value);
95
+ if (!this.warnedFull) {
96
+ this.warnedFull = true;
97
+ this.warnOnce(`[stigg] more than ${OPEN_RUNS_LIMIT} LangChain runs are open at once, so the oldest are being ` +
98
+ 'forgotten: a step beneath one is governed by the shared context rather than as its agent.');
99
+ }
100
+ }
101
+ /** Says a thing once per process - and never fails a step for the logger's sake. */
102
+ warnOnce(message) {
103
+ if (this.warned.has(message) || this.warned.size >= 100)
104
+ return;
105
+ this.warned.add(message);
106
+ (0, hooks_1.safely)(() => this.warn(message), () => undefined);
107
+ }
108
+ end(runId) {
109
+ this.open.delete(runId);
110
+ }
111
+ /** The agent a run belongs to: itself when it is one, otherwise whatever its parent belongs to. */
112
+ agentAt(runId) {
113
+ const run = runId === undefined ? undefined : this.open.get(runId);
114
+ if (!run)
115
+ return undefined; // the top of the tree, or a run this handler never saw start
116
+ if (run.name !== undefined && this.isAgent(run.name))
117
+ return run.name;
118
+ return this.agentAt(run.parent);
119
+ }
120
+ isAgent(name) {
121
+ return this.agents.has(name);
122
+ }
123
+ isOpen(runId) {
124
+ return this.open.has(runId);
125
+ }
126
+ /**
127
+ * The `stiggContext`s stated from the top of the tree down to a run, outermost first. LangChain
128
+ * merges `metadata` one key deep, so a node that sets its own `stiggContext` replaces the run's
129
+ * rather than adding to it - walking the tree puts the run's back underneath. A context a run
130
+ * merely inherited is the same object as its parent's, and is applied once.
131
+ */
132
+ stiggContextsAt(runId) {
133
+ const run = runId === undefined ? undefined : this.open.get(runId);
134
+ if (!run)
135
+ return [];
136
+ const above = this.stiggContextsAt(run.parent);
137
+ return run.stiggContext && run.stiggContext !== above.at(-1) ? [...above, run.stiggContext] : above;
138
+ }
139
+ }
41
140
  class StiggLangChainCallbackHandler extends base_1.BaseCallbackHandler {
42
141
  name = 'StiggLangChainCallbackHandler';
43
142
  /**
@@ -46,63 +145,139 @@ class StiggLangChainCallbackHandler extends base_1.BaseCallbackHandler {
46
145
  * given (`stigg.flush()`), and reported through the `hooks` it was configured with.
47
146
  */
48
147
  middleware;
148
+ runs;
49
149
  constructor(options) {
50
150
  super({ raiseError: true, _awaitHandler: true });
51
151
  if (options instanceof StiggLangChainCallbackHandler) {
52
- // LangChain's own copy, on its way into a nested run: the same graph, so the same middleware.
152
+ // LangChain's own copy, on its way into a nested run: the same graph, so the same state.
53
153
  this.middleware = options.middleware;
154
+ this.runs = options.runs;
54
155
  return;
55
156
  }
56
- const { stigg, ...middlewareOptions } = options;
157
+ const { stigg, rootAgent, ...middlewareOptions } = options;
158
+ if (rootAgent !== undefined && !options.agents?.[rootAgent]) {
159
+ throw new Error(`\`rootAgent\` names "${rootAgent}", which \`agents\` does not list - it must be one of them.`);
160
+ }
57
161
  if (!stigg || typeof stigg.middleware !== 'function') {
58
162
  throw new Error('`StiggLangChainCallbackHandler` needs `stigg`: the `StiggAgentSDK` it governs through.');
59
163
  }
60
164
  this.middleware = stigg.middleware({ ...middlewareOptions, missingCustomer: MISSING_CUSTOMER });
165
+ this.runs = new RunTree(new Set(Object.keys(options.agents ?? {})), rootAgent, (message) => stigg.config.logger.warn(message));
61
166
  }
62
- handleChatModelStart(_llm, _messages, runId, _parentRunId, _extraParams, _tags, metadata) {
63
- return this.middleware.startModel(runId, attributionOf(metadata), 'langchain.chat_model');
167
+ // The declared signature puts `runType` fourth and `parentRunId` last, but the callback manager
168
+ // calls this with `parentRunId` fourth and `runName` eighth - so it is read by what is passed.
169
+ // True of `@langchain/core` 1.2; the attribution tests run against the real manager, so a release
170
+ // that changes either side fails them.
171
+ handleChainStart(...args) {
172
+ const [, , runId, parentRunId, , metadata, , runName] = args;
173
+ this.runs.start(runId, parentRunId, runName, this.stiggContextOf(metadata, parentRunId));
174
+ }
175
+ handleChainEnd(_outputs, runId) {
176
+ this.runs.end(runId);
177
+ }
178
+ handleChainError(_err, runId) {
179
+ this.runs.end(runId);
180
+ }
181
+ handleChatModelStart(_llm, _messages, runId, parentRunId, _extraParams, _tags, metadata) {
182
+ return this.middleware.startModel(runId, this.attributionOf(parentRunId, metadata), 'langchain.chat_model');
64
183
  }
65
184
  // The completion-model path, for an LLM that is not a chat model. Same gate, same meter.
66
- handleLLMStart(_llm, _prompts, runId, _parentRunId, _extraParams, _tags, metadata) {
67
- return this.middleware.startModel(runId, attributionOf(metadata), 'langchain.llm');
185
+ handleLLMStart(_llm, _prompts, runId, parentRunId, _extraParams, _tags, metadata) {
186
+ return this.middleware.startModel(runId, this.attributionOf(parentRunId, metadata), 'langchain.llm');
68
187
  }
69
188
  handleLLMEnd(output, runId) {
70
- const message = output?.generations?.[0]?.[0]?.message;
189
+ const result = output;
190
+ const message = result?.generations?.[0]?.[0]?.message;
71
191
  const issued = (message?.tool_calls ?? []).flatMap((call) => call.id && call.name ? [{ id: call.id, name: call.name }] : []);
72
- return this.middleware.endModel(runId, measurementOf(message), issued);
192
+ return this.middleware.endModel(runId, measurementOf(message) ?? completionMeasurement(result, runId), issued);
73
193
  }
74
194
  handleLLMError(_err, runId) {
75
195
  this.middleware.abandon(runId);
76
196
  }
77
- handleToolStart(tool, input, runId, _parentRunId, _tags, metadata, runName, toolCallId) {
197
+ handleToolStart(tool, input, runId, parentRunId, _tags, metadata, runName, toolCallId) {
78
198
  // `runName` is set only for a tool built with `tool()`; a class-based one - `TavilySearch`,
79
199
  // most packaged integrations - starts with none, and a serialized form that names only its
80
200
  // class. The middleware names it from the model's own tool call, or by that class when no
81
201
  // model called it.
82
202
  const serialized = tool;
83
- return this.middleware.startTool(runId, { name: runName ?? serialized?.name, callId: toolCallId, kind: serialized?.id?.at(-1), input: decoded(input) }, attributionOf(metadata));
203
+ const name = runName ?? serialized?.name;
204
+ // A tool is a run too: a sub-agent it starts belongs below it in the tree.
205
+ this.runs.start(runId, parentRunId, name, this.stiggContextOf(metadata, parentRunId));
206
+ return this.middleware.startTool(runId, { name, callId: toolCallId, kind: serialized?.id?.at(-1), input: decoded(input) }, this.attributionOf(parentRunId, metadata));
84
207
  }
85
208
  handleToolEnd(output, runId) {
86
- // A tool the model called returns a `ToolMessage`, its result serialized into `content`.
87
- const content = isToolMessage(output) ? output.content : output;
88
- return this.middleware.endTool(runId, decoded(content));
209
+ this.runs.end(runId);
210
+ // A tool the model called returns a `ToolMessage`, its result serialized into `content` - and
211
+ // its name, for a tool that started without one.
212
+ const message = isToolMessage(output) ? output : undefined;
213
+ return this.middleware.endTool(runId, decoded(message ? message.content : output), message?.name);
89
214
  }
90
215
  handleToolError(_err, runId) {
216
+ this.runs.end(runId);
91
217
  this.middleware.abandon(runId);
92
218
  }
219
+ /**
220
+ * Who a step is for: the configured agent above it in the run tree, and the `stiggContext`s the
221
+ * run stated on the way down. With no agent above it, a name from metadata is passed on only as a
222
+ * label for the "not listed" warning - never a configured agent's name, which the tree alone can
223
+ * confer.
224
+ */
225
+ attributionOf(parentRunId, metadata) {
226
+ const label = metadata?.[AGENT_METADATA_KEY] ?? metadata?.[NODE_METADATA_KEY];
227
+ const unlisted = typeof label === 'string' && !this.runs.isAgent(label) ? label : undefined;
228
+ const agent = this.runs.agentAt(parentRunId);
229
+ // Metadata naming a configured agent the tree does not find is a misconfiguration - a subgraph
230
+ // node named apart from its agent, a top-level agent with no `rootAgent` - or a caller's attempt.
231
+ if (agent === undefined && typeof label === 'string' && this.runs.isAgent(label)) {
232
+ this.runs.warnOnce(`[stigg] a step's metadata names agent "${label}", but no run above it is named "${label}" - it is ` +
233
+ 'governed by the shared context. Name the node or the agent after it, or set `rootAgent` if it is ' +
234
+ 'the top-level agent.');
235
+ }
236
+ const above = this.runs.stiggContextsAt(parentRunId);
237
+ const own = this.stiggContextOf(metadata, parentRunId);
238
+ // A step's own `stiggContext` replaced the run's in its metadata; only the tree puts the run's
239
+ // back. A parent this handler never saw start - the handler given to a model rather than to
240
+ // `invoke` - leaves the step with its own context alone, and the run's customer gone.
241
+ if (own && own.customerId === undefined && parentRunId !== undefined && !this.runs.isOpen(parentRunId)) {
242
+ this.runs.warnOnce("[stigg] a step's own stiggContext names no customer, and the run above it was never seen by this " +
243
+ "handler - so the run's stiggContext is lost. Pass the handler at `invoke` (`callbacks: [handler]`), " +
244
+ 'not to a model or tool.');
245
+ }
246
+ return {
247
+ agent: agent ?? unlisted,
248
+ stiggContexts: own && own !== above.at(-1) ? [...above, own] : above,
249
+ };
250
+ }
251
+ /**
252
+ * The `stiggContext` a run's metadata states, with the customer the deprecated key names. That key
253
+ * is what a server's auth handler was told to set, so it is the one trusted: a `stiggContext`
254
+ * naming another customer beside it is refused rather than charged to someone else. A run with no
255
+ * parent - `invoke`'s own metadata - states only `RUN_FIELDS`, with the deprecated key or without.
256
+ */
257
+ stiggContextOf(metadata, parentRunId) {
258
+ const stated = metadata?.[exports.CONTEXT_METADATA_KEY];
259
+ const customerId = metadata?.[exports.CUSTOMER_METADATA_KEY];
260
+ if (stated !== undefined && (typeof stated !== 'object' || stated === null || Array.isArray(stated))) {
261
+ throw new Error(`metadata.${exports.CONTEXT_METADATA_KEY} must be a \`stiggContext\` object, not ${String(stated)}.`);
262
+ }
263
+ const unlisted = parentRunId === undefined && stated ? Object.keys(stated).filter((key) => !RUN_FIELDS.has(key)) : [];
264
+ if (unlisted.length) {
265
+ throw new Error(`metadata.${exports.CONTEXT_METADATA_KEY} on a run's \`invoke\` names only the customer and \`correlationId\`, not ` +
266
+ `${unlisted.join(', ')} - a client can write that metadata. Set the rest on the handler, under \`agents\`, ` +
267
+ "or in a node's own config.");
268
+ }
269
+ if (typeof customerId !== 'string' || !customerId)
270
+ return stated;
271
+ this.runs.warnOnce(`[stigg] metadata.${exports.CUSTOMER_METADATA_KEY} is deprecated - pass \`metadata: { ${exports.CONTEXT_METADATA_KEY}: { customerId } }\` instead.`);
272
+ const context = stated;
273
+ if (context?.customerId !== undefined && context.customerId !== customerId) {
274
+ throw new Error(`metadata.${exports.CONTEXT_METADATA_KEY}.customerId (${String(context.customerId)}) differs from metadata.${exports.CUSTOMER_METADATA_KEY} ` +
275
+ `(${customerId}) - name the customer once, in metadata.${exports.CONTEXT_METADATA_KEY}.`);
276
+ }
277
+ return { ...context, customerId };
278
+ }
93
279
  }
94
280
  exports.StiggLangChainCallbackHandler = StiggLangChainCallbackHandler;
95
- /** Who a LangChain step is for: the agent (its `createAgent` name, or its graph node), and the customer. */
96
- function attributionOf(metadata) {
97
- // `createAgent`'s name first: inside one, the node is an internal one (`model_request`). In a
98
- // hand-built `StateGraph` there is no agent name, and the node is the agent.
99
- const agent = metadata?.[AGENT_METADATA_KEY] ?? metadata?.[NODE_METADATA_KEY];
100
- const customerId = metadata?.[exports.CUSTOMER_METADATA_KEY];
101
- return {
102
- agent: typeof agent === 'string' ? agent : undefined,
103
- customerId: typeof customerId === 'string' && customerId ? customerId : undefined,
104
- };
105
- }
106
281
  /** LangChain's `ToolMessage`, told by shape - the class is the application's copy, not ours. */
107
282
  function isToolMessage(value) {
108
283
  return (typeof value === 'object' &&
@@ -139,6 +314,26 @@ function measurementOf(message) {
139
314
  ...(message.id ? { idempotencyKey: message.id } : {}),
140
315
  };
141
316
  }
317
+ /**
318
+ * A completion model's usage, which LangChain reports on `llmOutput.tokenUsage` rather than on a
319
+ * message - in camelCase, so it is mapped onto the same names a chat model reports under.
320
+ */
321
+ function completionMeasurement(result, runId) {
322
+ const usage = result?.llmOutput?.tokenUsage;
323
+ if (!usage)
324
+ return undefined;
325
+ const raw = {
326
+ input_tokens: usage.promptTokens,
327
+ output_tokens: usage.completionTokens,
328
+ total_tokens: usage.totalTokens,
329
+ };
330
+ // An empty `tokenUsage` - a streamed completion's - says nothing, and is no measurement: reported
331
+ // as zero it would look like a call that cost nothing, rather than one nobody measured.
332
+ if (!Object.values(raw).some((value) => typeof value === 'number'))
333
+ return undefined;
334
+ // No provider message id to key on, so the run's own: the same run reported twice is one report.
335
+ return { metrics: pickNumbers(raw, LANGCHAIN_METRICS), usage: (0, token_usage_1.normalizeUsage)(raw), raw, idempotencyKey: runId };
336
+ }
142
337
  /** The named counters that are numbers, as they were stated. */
143
338
  function pickNumbers(source, names) {
144
339
  const metrics = {};
@@ -144,6 +144,11 @@ export declare const CONTEXT_DEFAULTS: {
144
144
  readonly checkErrorAction: "allow";
145
145
  readonly ingestMode: "async";
146
146
  };
147
+ /**
148
+ * A plan id as given, trimmed - or a refusal when it was given blank. Absent stays absent: no
149
+ * plan is a legal choice, while an empty one is a typo that would provision nobody onto nothing.
150
+ */
151
+ export declare function planId(value: unknown, field?: string): string | undefined;
147
152
  /**
148
153
  * The one field a call cannot proceed without, refused the same way from every binding. Only where
149
154
  * to put the customer differs, so the remedy is the caller's to word.
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.addMetrics = exports.isEnforced = exports.noCustomer = exports.CONTEXT_DEFAULTS = void 0;
4
+ exports.planId = planId;
4
5
  exports.resolveContext = resolveContext;
5
6
  exports.overrideContext = overrideContext;
6
7
  /**
@@ -62,12 +63,14 @@ function agentDimension(context) {
62
63
  * A plan id as given, trimmed - or a refusal when it was given blank. Absent stays absent: no
63
64
  * plan is a legal choice, while an empty one is a typo that would provision nobody onto nothing.
64
65
  */
65
- function planId(value) {
66
+ function planId(value, field = 'stiggContext.defaultPlanId') {
66
67
  if (value === undefined)
67
68
  return undefined;
68
- const named = typeof value === 'string' ? value.trim() : '';
69
+ if (typeof value !== 'string')
70
+ throw new Error(`${field} must be a plan id string, not ${typeof value}.`);
71
+ const named = value.trim();
69
72
  if (!named) {
70
- throw new Error('stiggContext.defaultPlanId is empty - leave the field out to provision no plan, or name the ' +
73
+ throw new Error(`${field} is empty - leave the field out to provision no plan, or name the ` +
71
74
  'plan new customers should start on.');
72
75
  }
73
76
  return named;
@@ -178,7 +181,32 @@ function assertKnownContext(context, where) {
178
181
  throw new Error(`${where} has no field \`${key}\` - this SDK would ignore it, and the call would be ` +
179
182
  `governed by whatever remained instead. Valid fields: ${Object.keys(CONTEXT_KEYS).join(', ')}.`);
180
183
  }
184
+ // The shapes a JavaScript caller - or a run's metadata - can get wrong without a compiler to say
185
+ // so: each would otherwise surface later as a bare TypeError, or as a call that looks unmetered.
186
+ for (const key of STRING_FIELDS) {
187
+ const value = context[key];
188
+ if (value !== undefined && typeof value !== 'string') {
189
+ throw new Error(`${where}.${key} must be a string, not ${typeof value}.`);
190
+ }
191
+ }
192
+ if (context.usageMapper !== undefined && typeof context.usageMapper !== 'function') {
193
+ throw new Error(`${where}.usageMapper must be a function, not ${typeof context.usageMapper}.`);
194
+ }
195
+ const { dimensions } = context;
196
+ if (dimensions !== undefined &&
197
+ (typeof dimensions !== 'object' || dimensions === null || Array.isArray(dimensions))) {
198
+ throw new Error(`${where}.dimensions must be an object of dimension values.`);
199
+ }
200
+ // Values are matched against entity ids, which are strings: a number would match nothing, and
201
+ // the call would look attributed while governing nothing. An absent one is left to mean absent.
202
+ for (const [key, value] of Object.entries(dimensions ?? {})) {
203
+ if (value !== undefined && typeof value !== 'string') {
204
+ throw new Error(`${where}.dimensions.${key} must be a string, not ${typeof value}.`);
205
+ }
206
+ }
181
207
  }
208
+ /** The fields that are strings or absent. The customer and the plan say so in messages of their own. */
209
+ const STRING_FIELDS = ['agentId', 'attributionKey', 'idempotencyKey', 'correlationId'];
182
210
  /**
183
211
  * One call's override, applied over what `wrap` already resolved.
184
212
  *
@@ -203,9 +203,9 @@ export declare class GovernanceSession {
203
203
  private hook;
204
204
  /**
205
205
  * Makes sure a customer with a default plan exists. Never throws, and answers whether Stigg was
206
- * reached - false only for an outage, which is reported here and retried by the next call. A
207
- * refusal is an answer: the SDK reports it once, and the call goes on to meet whatever the check
208
- * says about a customer that does not exist.
206
+ * reached - false for a passing failure (unreachable, rate limited, timed out), which is reported
207
+ * here and retried after a short wait. A refusal is an answer: the SDK reports it once, and the
208
+ * call goes on to meet whatever the check says about a customer that does not exist.
209
209
  */
210
210
  private provisionCustomer;
211
211
  /**
@@ -434,9 +434,9 @@ class GovernanceSession {
434
434
  }
435
435
  /**
436
436
  * Makes sure a customer with a default plan exists. Never throws, and answers whether Stigg was
437
- * reached - false only for an outage, which is reported here and retried by the next call. A
438
- * refusal is an answer: the SDK reports it once, and the call goes on to meet whatever the check
439
- * says about a customer that does not exist.
437
+ * reached - false for a passing failure (unreachable, rate limited, timed out), which is reported
438
+ * here and retried after a short wait. A refusal is an answer: the SDK reports it once, and the
439
+ * call goes on to meet whatever the check says about a customer that does not exist.
440
440
  */
441
441
  async provisionCustomer(context) {
442
442
  if (!context.defaultPlanId || !context.customerId || !this.deps.provisionCustomer)
@@ -126,6 +126,8 @@ export type EntityPlacement = {
126
126
  */
127
127
  usageLimitOnCreate?: number;
128
128
  cadence?: string;
129
+ /** The entities the budget is scoped to - its own assignment beside the agent's whole one. */
130
+ scopeEntityIds?: string[];
129
131
  };
130
132
  export type EntityInput = {
131
133
  id: string;
@@ -94,9 +94,10 @@ export declare class NodeSdkEngine implements GovernanceEngine {
94
94
  * mutation, and writes the entitlements it answers with straight into the cache - so the check
95
95
  * that follows reads the new plan locally instead of fetching it.
96
96
  *
97
- * Throws only when Stigg did not answer. Any answer - created, duplicate, or refused - resolves,
98
- * because the caller treats them differently: a refusal will be the same next time, and an
99
- * unreachable server may not.
97
+ * Resolves with a final answer - created, duplicate, or a refusal that will be the same next
98
+ * time. Throws for a passing failure - unreachable, rate limited, timed out - so the customer is
99
+ * asked again later: remembering one as a refusal would deny that customer until the process
100
+ * restarts.
100
101
  */
101
102
  provisionCustomer(customerId: string, planId: string): Promise<ProvisionOutcome>;
102
103
  upsertEntity(): Promise<void>;
@@ -57,6 +57,20 @@ const report_1 = require("./report");
57
57
  * connection, a dropped socket - which is what tells a refusal apart from an outage.
58
58
  */
59
59
  const errorCode = (err) => err?.errorCode;
60
+ /**
61
+ * The failures that say nothing about the customer - a rate limit, a timeout, an unexpected error -
62
+ * so asking again later may succeed. Every other code Stigg answers with is a final answer about
63
+ * this customer or this configuration - an unknown plan, a key that cannot write, an invalid id -
64
+ * and asking again gets the same one.
65
+ */
66
+ const PASSING_FAILURES = new Set([
67
+ 'RateLimitExceeded',
68
+ 'OperationTimeout',
69
+ 'UnexpectedError',
70
+ 'RequestTimeout',
71
+ // A lock another request holds on the customer - creating one takes it.
72
+ 'ObjectAlreadyBeingUsedByAnotherRequestError',
73
+ ]);
60
74
  const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
61
75
  /**
62
76
  * Drops keys that were never set, so a spread cannot un-set a default.
@@ -148,9 +162,10 @@ class NodeSdkEngine {
148
162
  * mutation, and writes the entitlements it answers with straight into the cache - so the check
149
163
  * that follows reads the new plan locally instead of fetching it.
150
164
  *
151
- * Throws only when Stigg did not answer. Any answer - created, duplicate, or refused - resolves,
152
- * because the caller treats them differently: a refusal will be the same next time, and an
153
- * unreachable server may not.
165
+ * Resolves with a final answer - created, duplicate, or a refusal that will be the same next
166
+ * time. Throws for a passing failure - unreachable, rate limited, timed out - so the customer is
167
+ * asked again later: remembering one as a refusal would deny that customer until the process
168
+ * restarts.
154
169
  */
155
170
  async provisionCustomer(customerId, planId) {
156
171
  try {
@@ -165,7 +180,7 @@ class NodeSdkEngine {
165
180
  const code = errorCode(err);
166
181
  if (code === 'DuplicatedEntityNotAllowed')
167
182
  return { status: 'existing' };
168
- if (code && code !== 'RequestTimeout') {
183
+ if (code && !PASSING_FAILURES.has(code)) {
169
184
  return { status: 'rejected', code, reason: err instanceof Error ? err.message : String(err) };
170
185
  }
171
186
  throw (0, errors_1.asEngineError)('provisionCustomer', err);
@@ -87,6 +87,8 @@ class RestGovernanceEngine {
87
87
  ...capability,
88
88
  // Tri-state: omitted leaves the parent alone, null makes it a root, an id re-homes it.
89
89
  ...(placement.parentId === undefined ? {} : { parentId: placement.parentId }),
90
+ // Part of the assignment's identity: a scoped budget is its own row beside the whole one.
91
+ ...(placement.scopeEntityIds ? { scopeEntityIds: placement.scopeEntityIds } : {}),
90
92
  ...budget,
91
93
  },
92
94
  ],
@@ -107,14 +109,18 @@ class RestGovernanceEngine {
107
109
  return { usageLimit: placement.usageLimit, ...cadence };
108
110
  if (placement.usageLimitOnCreate === undefined)
109
111
  return cadence;
110
- const existing = await this.client.v1Beta.customers.assignments.list(customerId, {
111
- entityId,
112
- ...capability,
113
- limit: 1,
114
- });
115
- // Already assigned: leave the limit and its window exactly as they are.
116
- if (existing.data.length > 0)
117
- return {};
112
+ // An assignment is the `(entity, capability, scope)` triple, so "exists" means one with this
113
+ // scope - the agent's whole budget existing says nothing about its per-model one.
114
+ // Encoded whole, not joined: an id may hold a comma, and ['a,b'] is not ['a', 'b'].
115
+ const scopeOf = (ids) => JSON.stringify([...(ids ?? [])].sort());
116
+ const scope = scopeOf(placement.scopeEntityIds);
117
+ // Every page: the one with this scope can sit past the first, and missing it would overwrite
118
+ // an admin's limit with the seed.
119
+ for await (const row of this.client.v1Beta.customers.assignments.list(customerId, { entityId, ...capability })) {
120
+ // Already assigned: leave the limit and its window exactly as they are.
121
+ if (scopeOf(row.scopeEntityIds) === scope)
122
+ return {};
123
+ }
118
124
  return { usageLimit: placement.usageLimitOnCreate, ...cadence };
119
125
  }
120
126
  async check(customerId, { entitlement, requestedUsage, dimensions }) {
@@ -9,6 +9,6 @@
9
9
  * import { StiggAgentSDK } from "@stigg/agent-sdk-js";
10
10
  * import { StiggLangChainCallbackHandler, AccessDeniedError } from "@stigg/agent-sdk-js/langchain";
11
11
  */
12
- export { StiggLangChainCallbackHandler, CUSTOMER_METADATA_KEY, } from '../bindings/orchestrator/langchain/callback-handler';
12
+ export { StiggLangChainCallbackHandler, CONTEXT_METADATA_KEY, CUSTOMER_METADATA_KEY, } from '../bindings/orchestrator/langchain/callback-handler';
13
13
  export type { StiggLangChainCallbackOptions } from '../bindings/orchestrator/langchain/callback-handler';
14
14
  export { AccessDeniedError, isAccessDenied } from '../errors';
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.isAccessDenied = exports.AccessDeniedError = exports.CUSTOMER_METADATA_KEY = exports.StiggLangChainCallbackHandler = void 0;
3
+ exports.isAccessDenied = exports.AccessDeniedError = exports.CUSTOMER_METADATA_KEY = exports.CONTEXT_METADATA_KEY = exports.StiggLangChainCallbackHandler = void 0;
4
4
  /**
5
5
  * `@stigg/agent-sdk-js/langchain` - Stigg governance for LangChain and LangGraph.
6
6
  *
@@ -14,6 +14,7 @@ exports.isAccessDenied = exports.AccessDeniedError = exports.CUSTOMER_METADATA_K
14
14
  */
15
15
  var callback_handler_1 = require("../bindings/orchestrator/langchain/callback-handler");
16
16
  Object.defineProperty(exports, "StiggLangChainCallbackHandler", { enumerable: true, get: function () { return callback_handler_1.StiggLangChainCallbackHandler; } });
17
+ Object.defineProperty(exports, "CONTEXT_METADATA_KEY", { enumerable: true, get: function () { return callback_handler_1.CONTEXT_METADATA_KEY; } });
17
18
  Object.defineProperty(exports, "CUSTOMER_METADATA_KEY", { enumerable: true, get: function () { return callback_handler_1.CUSTOMER_METADATA_KEY; } });
18
19
  // What a refusal is, re-exported so a catch block needs one import: `e instanceof AccessDeniedError`.
19
20
  var errors_1 = require("../errors");
@@ -165,12 +165,18 @@ export declare class StiggAgentSDK {
165
165
  *
166
166
  * A refusal is remembered like a success: an unknown plan or a key that cannot write will be
167
167
  * refused the same way next time, and retrying it would spend a rate-limited call on every call.
168
- * Only an unreachable Stigg is forgotten, so the customer's next call tries again.
168
+ * A passing failure - unreachable, rate limited - is remembered for
169
+ * {@link PROVISION_RETRY_AFTER_MS}, then forgotten, so the customer is asked again: soon, but not
170
+ * on every call while Stigg is the one asking for less.
169
171
  *
170
172
  * Capped at {@link KNOWN_CUSTOMERS_CAP}, oldest first. Forgetting a customer costs one more
171
173
  * provisioning call, which Stigg answers as a duplicate - so the cap bounds memory, not safety.
172
174
  */
173
175
  private readonly customers;
176
+ /** When a customer whose provisioning failed in passing may be asked again. */
177
+ private readonly provisionRetryAt;
178
+ /** Not readonly, so a test can shorten the wait rather than sleep through it. */
179
+ private provisionRetryAfterMs;
174
180
  private started?;
175
181
  /**
176
182
  * When the attempt currently in `started` rejected, or undefined while it is pending or has
@@ -126,6 +126,8 @@ const RETRY_START_AFTER_MS = 2_000;
126
126
  * of tenants would otherwise grow this for as long as it runs.
127
127
  */
128
128
  const KNOWN_CUSTOMERS_CAP = 10_000;
129
+ /** How long a customer whose provisioning failed in passing waits before it is asked again. */
130
+ const PROVISION_RETRY_AFTER_MS = 30_000;
129
131
  /** The key shorthand and the options object, folded into the one object the config takes. */
130
132
  function toOptions(first, second) {
131
133
  return typeof first === 'string' ? { ...second, apiKey: first } : { ...first, ...second };
@@ -134,7 +136,7 @@ function toOptions(first, second) {
134
136
  function toPlacement(input) {
135
137
  if (!input.budget)
136
138
  return undefined;
137
- const { entitlement, cadence, parentId } = input.budget;
139
+ const { entitlement, cadence, parentId, scope } = input.budget;
138
140
  // A placement writes an assignment against the entitlement, so an empty one persists a budget
139
141
  // attached to nothing rather than merely routing badly.
140
142
  const invalid = (0, types_1.entitlementError)(entitlement);
@@ -151,12 +153,19 @@ function toPlacement(input) {
151
153
  "`budget.apply: 'on-create'` writes a cap the first time and never again, so there is " +
152
154
  'nothing to write without `budget.limit`.');
153
155
  }
156
+ // Each entry names an entity, and a blank one names none: the budget would be scoped to an
157
+ // entity no request resolves, and apply to nothing.
158
+ if (scope !== undefined && (!Array.isArray(scope) || scope.some((id) => typeof id !== 'string' || !id.trim()))) {
159
+ throw new Error(`Agent ${input.agentId} has a budget.scope that is not a list of entity ids: ${JSON.stringify(scope)}. ` +
160
+ 'Leave it out, or pass [], for the whole budget.');
161
+ }
154
162
  return {
155
163
  parentId,
156
164
  entitlement,
157
165
  cadence,
158
166
  usageLimit: onCreate ? undefined : input.budget.limit,
159
167
  usageLimitOnCreate: onCreate ? (input.budget.limit ?? undefined) : undefined,
168
+ scopeEntityIds: scope?.length ? scope : undefined,
160
169
  };
161
170
  }
162
171
  class StiggAgentSDK {
@@ -196,12 +205,18 @@ class StiggAgentSDK {
196
205
  *
197
206
  * A refusal is remembered like a success: an unknown plan or a key that cannot write will be
198
207
  * refused the same way next time, and retrying it would spend a rate-limited call on every call.
199
- * Only an unreachable Stigg is forgotten, so the customer's next call tries again.
208
+ * A passing failure - unreachable, rate limited - is remembered for
209
+ * {@link PROVISION_RETRY_AFTER_MS}, then forgotten, so the customer is asked again: soon, but not
210
+ * on every call while Stigg is the one asking for less.
200
211
  *
201
212
  * Capped at {@link KNOWN_CUSTOMERS_CAP}, oldest first. Forgetting a customer costs one more
202
213
  * provisioning call, which Stigg answers as a duplicate - so the cap bounds memory, not safety.
203
214
  */
204
215
  customers = new Map();
216
+ /** When a customer whose provisioning failed in passing may be asked again. */
217
+ provisionRetryAt = new Map();
218
+ /** Not readonly, so a test can shorten the wait rather than sleep through it. */
219
+ provisionRetryAfterMs = PROVISION_RETRY_AFTER_MS;
205
220
  started;
206
221
  /**
207
222
  * When the attempt currently in `started` rejected, or undefined while it is pending or has
@@ -262,6 +277,8 @@ class StiggAgentSDK {
262
277
  if (typeof agentId !== 'string' || !agentId.trim()) {
263
278
  throw new Error('register needs an agentId - an empty one provisions an entity nobody can name.');
264
279
  }
280
+ // Validated by presence, not truthiness: a blank plan is a typo, not a request for none.
281
+ const defaultPlanId = (0, context_1.planId)(input.defaultPlanId, 'register defaultPlanId');
265
282
  const placement = toPlacement(input);
266
283
  const key = `${customerId}:${agentId}`;
267
284
  // Memoized on the description, not just the pair: a second call describing the agent
@@ -284,6 +301,10 @@ class StiggAgentSDK {
284
301
  // built: `run()` already validates in this order, and a malformed call that reports itself as
285
302
  // a probe timeout sends the reader looking at the network for a typo they can see.
286
303
  await this.ensureStarted();
304
+ // The customer before the agent: an entity is created under a customer, so one Stigg has never
305
+ // seen would be refused. The same once-per-customer provisioning every check does.
306
+ if (defaultPlanId)
307
+ await this.provisionCustomer(customerId, defaultPlanId);
287
308
  if (!this.engine.capabilities.provisioning) {
288
309
  // Only a call that asked for something governance-only is an error. A budget or a parent
289
310
  // cannot be honoured, and carrying on would leave an agent that looks governed with a
@@ -428,6 +449,10 @@ class StiggAgentSDK {
428
449
  onUnlisted: (agent) => this.config.logger.warn(`[stigg] agent "${agent}" is not listed in the middleware's \`agents\`, so its calls are ` +
429
450
  'governed by the shared `stiggContext` alone - at the customer level unless that context ' +
430
451
  'names an agent. List it to govern it as itself.'),
452
+ onUncheckedTool: (tool) => this.config.logger.warn(`[stigg] tool "${tool}" was metered but not checked: it started with no name, so nothing ` +
453
+ "could match it until its result named it. Add `'*'` to `tools` to check tools like it."),
454
+ onUnnamedTool: (kind) => this.config.logger.warn(`[stigg] a tool${kind ? ` (${kind})` : ''} ran with no name, outside a model's tool call, so it ` +
455
+ "could not be checked or priced. If it is a paid tool, add `'*'` to `tools` to govern tools like it."),
431
456
  missingCustomer: options.missingCustomer ??
432
457
  'Name one per run, or give the middleware a default in `stiggContext: { customerId }`.',
433
458
  });
@@ -514,8 +539,12 @@ class StiggAgentSDK {
514
539
  */
515
540
  provisionCustomer(customerId, planId) {
516
541
  const known = this.customers.get(customerId);
517
- if (known)
542
+ const retryAt = this.provisionRetryAt.get(customerId);
543
+ // A passing failure answers every call until its wait is over - asked again on each one, a
544
+ // rate-limited Stigg would only be asked harder.
545
+ if (known && (retryAt === undefined || Date.now() < retryAt))
518
546
  return known;
547
+ this.provisionRetryAt.delete(customerId);
519
548
  const pending = this.ensureStarted().then(async ({ engine }) => {
520
549
  const outcome = await engine.provisionCustomer?.(customerId, planId);
521
550
  if (outcome?.status === 'created')
@@ -530,13 +559,16 @@ class StiggAgentSDK {
530
559
  });
531
560
  this.customers.set(customerId, pending);
532
561
  if (this.customers.size > KNOWN_CUSTOMERS_CAP) {
533
- this.customers.delete(this.customers.keys().next().value);
562
+ const oldest = this.customers.keys().next().value;
563
+ this.customers.delete(oldest);
564
+ this.provisionRetryAt.delete(oldest);
534
565
  }
535
566
  // Only its own entry: one evicted by the cap may have been replaced by a newer attempt for the
536
- // same customer by the time it fails, and deleting that one would re-ask a settled customer.
567
+ // same customer by the time it fails, and timing that one out would re-ask a settled customer.
537
568
  pending.catch(() => {
538
- if (this.customers.get(customerId) === pending)
539
- this.customers.delete(customerId);
569
+ if (this.customers.get(customerId) === pending) {
570
+ this.provisionRetryAt.set(customerId, Date.now() + this.provisionRetryAfterMs);
571
+ }
540
572
  });
541
573
  return pending;
542
574
  }
package/dist/types.d.ts CHANGED
@@ -359,8 +359,9 @@ export type ClientContext = Omit<StiggContext, 'idempotencyKey'> & {
359
359
  * agent and nothing else.
360
360
  *
361
361
  * The customer is the one thing usually left out, because that changes per request: it rides each
362
- * run instead, wherever the binding says (on LangChain, `metadata.stiggCustomerId`), and wins over a
363
- * default here. Set it server-side, from the authenticated user, never from the request.
362
+ * run instead, in the run's own `stiggContext` - the per-call override `wrap` takes - wherever the
363
+ * binding says (on LangChain, `metadata.stiggContext`), and wins over a default here. Set it
364
+ * server-side, from the authenticated user, never from the request.
364
365
  */
365
366
  export type MiddlewareOptions = {
366
367
  /**
@@ -498,6 +499,18 @@ export type AgentBudget = {
498
499
  cadence?: string;
499
500
  /** Places the agent under a parent entity. Tri-state: omit to leave it, null to make it a root. */
500
501
  parentId?: string | null;
502
+ /**
503
+ * Narrows the budget to requests whose resolved entities include every one of these - a budget
504
+ * per value of a dimension, such as one per model: register each model as an entity of a type
505
+ * whose attribution key is the dimension, then scope one budget to each. Each scope is its own
506
+ * budget on the agent, so register once per scope. Omitted or empty, the budget is the agent's
507
+ * whole one.
508
+ *
509
+ * A scoped budget governs only the requests it names: one naming no model, or a model with no
510
+ * budget, is limited by the agent's whole budget and its ancestors' alone - with neither, by
511
+ * nothing. Register the whole budget too.
512
+ */
513
+ scope?: string[];
501
514
  } & ({
502
515
  /** The limit, written on every call. `null` clears whatever is assigned. */
503
516
  limit?: number | null;
@@ -519,7 +532,14 @@ export type AgentScope = {
519
532
  customerId: string;
520
533
  };
521
534
  /** `register` - the standalone call, which has no run to take the scope from. */
522
- export type RegisterInput = AgentOptions & AgentScope;
535
+ export type RegisterInput = AgentOptions & AgentScope & {
536
+ /**
537
+ * The plan to provision the customer onto first, when Stigg does not know them yet - the same
538
+ * provisioning a check with `stiggContext.defaultPlanId` does, once per customer per process.
539
+ * Without it, registering under a customer Stigg has never seen fails: an entity needs one.
540
+ */
541
+ defaultPlanId?: string;
542
+ };
523
543
  /** `deregister` - archiving the entity. No entitlement: an entity is not archived per-entitlement. */
524
544
  export type DeregisterInput = Pick<AgentScope, 'customerId'> & Pick<AgentOptions, 'agentId'>;
525
545
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stigg/agent-sdk-js",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
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",
@@ -49,7 +49,8 @@
49
49
  "example-client-typesafe": "tsx examples/client/typesafe.ts",
50
50
  "example-two-agents": "tsx examples/client/two-agents.ts",
51
51
  "example-orchestrator-langchain": "tsx examples/orchestrator/langchain.ts",
52
- "example-orchestrator-langgraph": "tsx examples/orchestrator/langgraph.ts",
52
+ "example-orchestrator-langgraph-handoff": "tsx examples/orchestrator/langgraph-handoff.ts",
53
+ "example-orchestrator-langgraph-model-fallback": "tsx examples/orchestrator/langgraph-model-fallback.ts",
53
54
  "example-gateway": "tsx examples/gateway/openrouter.ts",
54
55
  "example-gateway-openrouter": "tsx examples/gateway/openrouter.ts",
55
56
  "example-gateway-litellm": "tsx examples/gateway/litellm.ts",