@stigg/agent-sdk-js 0.5.1 β†’ 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
47
  endTool(runId: string, output?: unknown, name?: string): Promise<void>;
44
- /** A step that failed: forgotten, and billed nothing - it did not deliver what it charges for. */
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. */
@@ -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,7 +19,7 @@ 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");
@@ -69,7 +69,7 @@ function resolveAgents(shared, { tools, agents }) {
69
69
  // into the first. The shared context is refused the same way, in `resolveContext`.
70
70
  if (agent.stiggContext?.idempotencyKey !== undefined) {
71
71
  throw new Error(`stigg.middleware's agents.${name}.stiggContext cannot set \`idempotencyKey\` - one key shared ` +
72
- '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.');
73
73
  }
74
74
  resolved.set(name, {
75
75
  context: agent.stiggContext ? (0, context_1.overrideContext)(shared, agent.stiggContext) : shared,
@@ -125,7 +125,7 @@ function createAgentMiddleware(session, config) {
125
125
  const warnedUnlisted = new Set();
126
126
  /**
127
127
  * The agent a step belongs to. One the configuration does not list - or a step attributed to no
128
- * 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.
129
129
  */
130
130
  const agentFor = (agent) => {
131
131
  const own = agent === undefined ? undefined : config.agents.get(agent);
@@ -140,9 +140,21 @@ function createAgentMiddleware(session, config) {
140
140
  }
141
141
  return own ?? config.shared;
142
142
  };
143
- /** The context for one step. The customer is required: an empty one would be billed to nobody. */
144
- const contextFor = (base, { agent, customerId }) => {
145
- 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);
146
158
  if (!context.customerId)
147
159
  throw (0, context_1.noCustomer)(config.missingCustomer);
148
160
  return agent === undefined ? context : { ...context, agent };
@@ -155,7 +167,7 @@ function createAgentMiddleware(session, config) {
155
167
  if (!decision.hasAccess)
156
168
  throw new errors_1.AccessDeniedError(decision);
157
169
  };
158
- /** 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. */
159
171
  const rememberStep = (runId, step) => {
160
172
  const dropped = remember(steps, runId, step, STEPS_LIMIT);
161
173
  if (dropped)
@@ -175,11 +187,16 @@ function createAgentMiddleware(session, config) {
175
187
  for (const call of issued)
176
188
  remember(issuedCalls, call.id, { name: call.name, agent }, ISSUED_CALLS_LIMIT);
177
189
  };
178
- /** 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
+ */
179
195
  const toolContext = (agent, tool, name, attribution) => {
180
196
  const eventName = { name: `tool.${name}` };
181
- const priced = (0, context_1.overrideContext)(agent.context, tool.entitlement ? { entitlement: tool.entitlement, eventName } : { eventName });
182
- 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 };
183
200
  };
184
201
  /** Reports what a tool step that ended used: its `usage`, or one unit. A bad `usage` is a metering failure. */
185
202
  const ingestTool = async ({ context, idempotencyKey, tool }, output) => {
@@ -256,7 +273,7 @@ function createAgentMiddleware(session, config) {
256
273
  },
257
274
  async startTool(runId, { name, callId, kind, input }, attribution) {
258
275
  // Never taken, only looked up - the cap drops old ones: a tools node that runs again, before
259
- // 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.
260
277
  const issued = callId === undefined ? undefined : issuedCalls.get(callId);
261
278
  const named = name ?? issued?.name;
262
279
  const toolName = named ?? kind;
@@ -2,10 +2,10 @@
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 customer and
8
- * the usage are, which agent a step belongs to. Whether a step may run and what it is worth is the
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
9
  * middleware's. LangChain hands a callback to every run beneath the `invoke`, sub-agents and
10
10
  * LangGraph nodes included, so one handler governs the whole graph.
11
11
  *
@@ -13,7 +13,7 @@
13
13
  * `createReactAgent` by its `name`, a hand-built graph's node by its own. That is LangChain's run
14
14
  * tree, not metadata - `lc_agent_name` in metadata is inherited by an unnamed sub-agent, carried
15
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 bill the wrong agent or let a client pick whose budget pays.
16
+ * metadata, so it would charge the wrong agent or let a client pick whose budget pays.
17
17
  *
18
18
  * It blocks by default. LangChain swallows a handler's error unless `raiseError` is set, and runs
19
19
  * an async handler in the background unless `awaitHandlers` is set too, so both are set - through
@@ -28,10 +28,14 @@ import { BaseCallbackHandler } from '@langchain/core/callbacks/base';
28
28
  import type { StiggAgentSDK } from '../../../stigg-agent-sdk';
29
29
  import type { GovernedAgentMiddleware, MiddlewareOptions } from '../../../types';
30
30
  /**
31
- * The metadata key a caller names the customer under, on `invoke(input, { metadata })`. It wins
32
- * over the handler's default, so set it server-side - on LangGraph Platform the client sends
33
- * `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.
34
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`. */
35
39
  export declare const CUSTOMER_METADATA_KEY = "stiggCustomerId";
36
40
  export type StiggLangChainCallbackOptions = MiddlewareOptions & {
37
41
  /** The SDK this handler governs through - one per environment, shared with everything else. */
@@ -64,9 +68,17 @@ export declare class StiggLangChainCallbackHandler extends BaseCallbackHandler {
64
68
  handleToolEnd(output: unknown, runId: string): Promise<void>;
65
69
  handleToolError(_err: unknown, runId: string): void;
66
70
  /**
67
- * Who a step is for: the configured agent above it in the run tree, and the run's customer. With
68
- * none above it, a name from metadata is passed on only as a label for the "not listed" warning -
69
- * never a configured agent's name, which the tree alone can confer.
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.
70
75
  */
71
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;
72
84
  }
@@ -1,14 +1,14 @@
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 customer and
11
- * the usage are, which agent a step belongs to. Whether a step may run and what it is worth is the
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
12
  * middleware's. LangChain hands a callback to every run beneath the `invoke`, sub-agents and
13
13
  * LangGraph nodes included, so one handler governs the whole graph.
14
14
  *
@@ -16,7 +16,7 @@ exports.StiggLangChainCallbackHandler = exports.CUSTOMER_METADATA_KEY = void 0;
16
16
  * `createReactAgent` by its `name`, a hand-built graph's node by its own. That is LangChain's run
17
17
  * tree, not metadata - `lc_agent_name` in metadata is inherited by an unnamed sub-agent, carried
18
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 bill the wrong agent or let a client pick whose budget pays.
19
+ * metadata, so it would charge the wrong agent or let a client pick whose budget pays.
20
20
  *
21
21
  * It blocks by default. LangChain swallows a handler's error unless `raiseError` is set, and runs
22
22
  * an async handler in the background unless `awaitHandlers` is set too, so both are set - through
@@ -31,13 +31,24 @@ const base_1 = require("@langchain/core/callbacks/base");
31
31
  const hooks_1 = require("../../../core/hooks");
32
32
  const token_usage_1 = require("../../shared/token-usage");
33
33
  /**
34
- * The metadata key a caller names the customer under, on `invoke(input, { metadata })`. It wins
35
- * over the handler's default, so set it server-side - on LangGraph Platform the client sends
36
- * `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.
37
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.
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`. */
38
49
  exports.CUSTOMER_METADATA_KEY = 'stiggCustomerId';
39
50
  /**
40
- * What `createAgent({ name })` and LangGraph stamp in metadata. Never what a step is billed as -
51
+ * What `createAgent({ name })` and LangGraph stamp in metadata. Never what a step is reported as -
41
52
  * see the top of this file - only the label an unlisted agent is reported under.
42
53
  */
43
54
  const AGENT_METADATA_KEY = 'lc_agent_name';
@@ -47,7 +58,7 @@ const OPEN_RUNS_LIMIT = 10_000;
47
58
  /** The counters LangChain's `usage_metadata` states at the top level, reported under its own names. */
48
59
  const LANGCHAIN_METRICS = ['input_tokens', 'output_tokens', 'total_tokens'];
49
60
  /** Where a caller puts the customer, for the refusal a step with none raises. */
50
- 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 } } })\`, ` +
51
62
  'or give the handler a default in its `stiggContext: { customerId }`.';
52
63
  /**
53
64
  * The runs LangChain has open, each with its name and parent, so a step can be traced up to the
@@ -70,8 +81,12 @@ class RunTree {
70
81
  this.rootAgent = rootAgent;
71
82
  this.warn = warn;
72
83
  }
73
- start(runId, parent, name) {
74
- this.open.set(runId, { name: parent === undefined && this.rootAgent ? this.rootAgent : name, parent });
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
+ });
75
90
  if (this.open.size <= OPEN_RUNS_LIMIT)
76
91
  return;
77
92
  // A forgotten run breaks the walk for the steps beneath it: they fall back to the shared
@@ -105,6 +120,22 @@ class RunTree {
105
120
  isAgent(name) {
106
121
  return this.agents.has(name);
107
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
+ }
108
139
  }
109
140
  class StiggLangChainCallbackHandler extends base_1.BaseCallbackHandler {
110
141
  name = 'StiggLangChainCallbackHandler';
@@ -138,8 +169,8 @@ class StiggLangChainCallbackHandler extends base_1.BaseCallbackHandler {
138
169
  // True of `@langchain/core` 1.2; the attribution tests run against the real manager, so a release
139
170
  // that changes either side fails them.
140
171
  handleChainStart(...args) {
141
- const [, , runId, parentRunId, , , , runName] = args;
142
- this.runs.start(runId, parentRunId, runName);
172
+ const [, , runId, parentRunId, , metadata, , runName] = args;
173
+ this.runs.start(runId, parentRunId, runName, this.stiggContextOf(metadata, parentRunId));
143
174
  }
144
175
  handleChainEnd(_outputs, runId) {
145
176
  this.runs.end(runId);
@@ -171,7 +202,7 @@ class StiggLangChainCallbackHandler extends base_1.BaseCallbackHandler {
171
202
  const serialized = tool;
172
203
  const name = runName ?? serialized?.name;
173
204
  // A tool is a run too: a sub-agent it starts belongs below it in the tree.
174
- this.runs.start(runId, parentRunId, name);
205
+ this.runs.start(runId, parentRunId, name, this.stiggContextOf(metadata, parentRunId));
175
206
  return this.middleware.startTool(runId, { name, callId: toolCallId, kind: serialized?.id?.at(-1), input: decoded(input) }, this.attributionOf(parentRunId, metadata));
176
207
  }
177
208
  handleToolEnd(output, runId) {
@@ -186,14 +217,14 @@ class StiggLangChainCallbackHandler extends base_1.BaseCallbackHandler {
186
217
  this.middleware.abandon(runId);
187
218
  }
188
219
  /**
189
- * Who a step is for: the configured agent above it in the run tree, and the run's customer. With
190
- * none above it, a name from metadata is passed on only as a label for the "not listed" warning -
191
- * never a configured agent's name, which the tree alone can confer.
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.
192
224
  */
193
225
  attributionOf(parentRunId, metadata) {
194
226
  const label = metadata?.[AGENT_METADATA_KEY] ?? metadata?.[NODE_METADATA_KEY];
195
227
  const unlisted = typeof label === 'string' && !this.runs.isAgent(label) ? label : undefined;
196
- const customerId = metadata?.[exports.CUSTOMER_METADATA_KEY];
197
228
  const agent = this.runs.agentAt(parentRunId);
198
229
  // Metadata naming a configured agent the tree does not find is a misconfiguration - a subgraph
199
230
  // node named apart from its agent, a top-level agent with no `rootAgent` - or a caller's attempt.
@@ -202,11 +233,49 @@ class StiggLangChainCallbackHandler extends base_1.BaseCallbackHandler {
202
233
  'governed by the shared context. Name the node or the agent after it, or set `rootAgent` if it is ' +
203
234
  'the top-level agent.');
204
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
+ }
205
246
  return {
206
247
  agent: agent ?? unlisted,
207
- customerId: typeof customerId === 'string' && customerId ? customerId : undefined,
248
+ stiggContexts: own && own !== above.at(-1) ? [...above, own] : above,
208
249
  };
209
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
+ }
210
279
  }
211
280
  exports.StiggLangChainCallbackHandler = StiggLangChainCallbackHandler;
212
281
  /** LangChain's `ToolMessage`, told by shape - the class is the application's copy, not ours. */
@@ -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
  *
@@ -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;
@@ -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");
@@ -136,7 +136,7 @@ function toOptions(first, second) {
136
136
  function toPlacement(input) {
137
137
  if (!input.budget)
138
138
  return undefined;
139
- const { entitlement, cadence, parentId } = input.budget;
139
+ const { entitlement, cadence, parentId, scope } = input.budget;
140
140
  // A placement writes an assignment against the entitlement, so an empty one persists a budget
141
141
  // attached to nothing rather than merely routing badly.
142
142
  const invalid = (0, types_1.entitlementError)(entitlement);
@@ -153,12 +153,19 @@ function toPlacement(input) {
153
153
  "`budget.apply: 'on-create'` writes a cap the first time and never again, so there is " +
154
154
  'nothing to write without `budget.limit`.');
155
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
+ }
156
162
  return {
157
163
  parentId,
158
164
  entitlement,
159
165
  cadence,
160
166
  usageLimit: onCreate ? undefined : input.budget.limit,
161
167
  usageLimitOnCreate: onCreate ? (input.budget.limit ?? undefined) : undefined,
168
+ scopeEntityIds: scope?.length ? scope : undefined,
162
169
  };
163
170
  }
164
171
  class StiggAgentSDK {
@@ -270,6 +277,8 @@ class StiggAgentSDK {
270
277
  if (typeof agentId !== 'string' || !agentId.trim()) {
271
278
  throw new Error('register needs an agentId - an empty one provisions an entity nobody can name.');
272
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');
273
282
  const placement = toPlacement(input);
274
283
  const key = `${customerId}:${agentId}`;
275
284
  // Memoized on the description, not just the pair: a second call describing the agent
@@ -292,6 +301,10 @@ class StiggAgentSDK {
292
301
  // built: `run()` already validates in this order, and a malformed call that reports itself as
293
302
  // a probe timeout sends the reader looking at the network for a typo they can see.
294
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);
295
308
  if (!this.engine.capabilities.provisioning) {
296
309
  // Only a call that asked for something governance-only is an error. A budget or a parent
297
310
  // cannot be honoured, and carrying on would leave an agent that looks governed with a
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.1",
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",