@stigg/agent-sdk-js 0.4.0 → 0.5.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
@@ -121,6 +121,8 @@ rather than handing back a client that quietly meters nothing.
121
121
  | Give an agent its own budget | `stigg.register({ customerId, agentId, budget })` |
122
122
  | Start new customers on a plan | `stiggContext: { defaultPlanId }`, beside `customerId` |
123
123
  | Meter a gateway you operate | `client.stigg.headers()` out, `stigg.ingestFromHeaders(...)` back |
124
+ | Govern a LangChain / LangGraph agent graph ([guide](docs/langchain.md)) | `new StiggLangChainCallbackHandler({ stigg, agents, tools })` from `@stigg/agent-sdk-js/langchain` |
125
+ | Govern a graph from your own harness | `stigg.middleware({ stiggContext, agents })` |
124
126
 
125
127
  One wrapped client serves every customer and every agent: the context is an argument, given once at
126
128
  `wrap` as a default and overridable inside any single call.
@@ -7,11 +7,11 @@ export declare const mistralRecipe: {
7
7
  totalTokensMetric: "total_tokens";
8
8
  matches: (client: unknown) => boolean;
9
9
  methods: ({
10
- path: ("stream" | "chat" | "complete" | "parse" | "parseStream" | "fim" | "agents")[];
10
+ path: ("agents" | "stream" | "chat" | "complete" | "parse" | "parseStream" | "fim")[];
11
11
  attachStream: (stream: unknown, ingest: (measurement: Measurement) => void) => void;
12
12
  extractUsage?: undefined;
13
13
  } | {
14
- path: ("stream" | "chat" | "complete" | "parse" | "parseStream" | "fim" | "agents")[];
14
+ path: ("agents" | "stream" | "chat" | "complete" | "parse" | "parseStream" | "fim")[];
15
15
  extractUsage: (result: MistralCompletionResponse | undefined) => Measurement | undefined;
16
16
  attachStream?: undefined;
17
17
  })[];
@@ -0,0 +1,81 @@
1
+ import { type ResolvedContext } from '../../../core/context';
2
+ import type { GovernanceSession } from '../../../core/session';
3
+ import { type Entitlement, type Measurement, type MiddlewareOptions, type MiddlewareTools, type ToolUsageMapper } from '../../../types';
4
+ /** Who a step is for, as the framework stated it on that step. */
5
+ export type StepAttribution = {
6
+ /** The agent that took the step, by the framework's own name for it. */
7
+ agent?: string;
8
+ /** The customer the run is for, when the run named one. */
9
+ customerId?: string;
10
+ };
11
+ /** A tool call the model issued in its reply: the id the tool step will refer back to, and what it named. */
12
+ export type IssuedToolCall = {
13
+ id: string;
14
+ name: string;
15
+ };
16
+ /** A tool about to run, as the framework announced it. */
17
+ export type ToolStep = {
18
+ /** The tool's name, when the framework passes one - some do not, for some tools. */
19
+ name?: string;
20
+ /** The id of the model's tool call this step runs, when the framework passes it. */
21
+ callId?: string;
22
+ /**
23
+ * What the tool is when it has no name and no model called it - its class, for a packaged tool
24
+ * a node invokes directly. Only `'*'` can match it, so the catch-all still covers the call.
25
+ */
26
+ kind?: string;
27
+ /** What the tool was called with, for a `usage` mapper. */
28
+ input?: unknown;
29
+ };
30
+ /** What a framework binding drives: the start and end of every step a graph takes. */
31
+ export type AgentMiddleware = {
32
+ /** Before a model call: checked, and raises `AccessDeniedError` when refused. */
33
+ startModel(runId: string, attribution: StepAttribution, eventName: string): Promise<void>;
34
+ /**
35
+ * After a model call: its measurement is ingested, or the missing one reported. Never throws.
36
+ * `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.
38
+ */
39
+ endModel(runId: string, measurement: Measurement | undefined, issued?: readonly IssuedToolCall[]): Promise<void>;
40
+ /** Before a tool runs: checked when the tool is a paid one, raising when refused. */
41
+ startTool(runId: string, tool: ToolStep, attribution: StepAttribution): Promise<void>;
42
+ /** 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. */
45
+ abandon(runId: string): void;
46
+ };
47
+ /** How one tool is governed, its shorthand expanded. */
48
+ export type ResolvedTool = {
49
+ entitlement?: Entitlement;
50
+ usage?: ToolUsageMapper;
51
+ };
52
+ /** A graph's tools by name, `'*'` for the rest - `false` for one left ungoverned. */
53
+ export type ResolvedTools = Readonly<Record<string, ResolvedTool | false>>;
54
+ /** One agent's part of the graph, already resolved over the shared context. */
55
+ export type ResolvedAgent = {
56
+ context: ResolvedContext;
57
+ tools: ResolvedTools;
58
+ };
59
+ /** The key that stands for every tool a configuration does not name. */
60
+ export declare const ALL_TOOLS = "*";
61
+ /** A configuration's tools, each validated and expanded once, when the graph is configured. */
62
+ export declare function resolveTools(tools: MiddlewareTools | undefined, where: string): ResolvedTools;
63
+ /**
64
+ * The graph's agents, each resolved once over the shared context and tools, so a step does no
65
+ * more than look its agent up. An agent's tools are laid over the shared ones, name by name.
66
+ */
67
+ export declare function resolveAgents(shared: ResolvedContext, { tools, agents }: Pick<MiddlewareOptions, 'tools' | 'agents'>): {
68
+ shared: ResolvedAgent;
69
+ agents: Map<string, ResolvedAgent>;
70
+ };
71
+ export type AgentMiddlewareConfig = {
72
+ /** The shared context and tools - what a step from an agent not listed resolves to. */
73
+ shared: ResolvedAgent;
74
+ /** Each listed agent's own, by name. */
75
+ agents: ReadonlyMap<string, ResolvedAgent>;
76
+ /** Told once per agent that was not listed, so the caller can say so. */
77
+ onUnlisted(agent: string): void;
78
+ /** Where the caller puts the customer - quoted when a step has none. */
79
+ missingCustomer: string;
80
+ };
81
+ export declare function createAgentMiddleware(session: GovernanceSession, config: AgentMiddlewareConfig): AgentMiddleware;
@@ -0,0 +1,231 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ALL_TOOLS = void 0;
4
+ exports.resolveTools = resolveTools;
5
+ exports.resolveAgents = resolveAgents;
6
+ exports.createAgentMiddleware = createAgentMiddleware;
7
+ /**
8
+ * The orchestration binding's core: an agent graph's middleware, with no framework in it.
9
+ *
10
+ * It splits the way the client binding does: `wrapClient` gates and meters a call and a recipe
11
+ * knows one vendor; here the middleware governs a graph's steps and a framework binding (the
12
+ * LangChain callback handler, or a harness of your own) knows how its framework announces them.
13
+ *
14
+ * Every step is checked and billed as the agent that took it, so one agent running dry refuses
15
+ * that agent's steps and nothing else. A refusal is the SDK's own `AccessDeniedError`, thrown from
16
+ * inside the step - the frameworks worth binding to offer hooks that observe a step, not ones that
17
+ * replace its result - and what it means for the run is the graph's to decide. Nothing else throws
18
+ * into the run: ingest never does, and a check that could not be made resolves through
19
+ * `checkErrorAction`.
20
+ *
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.
23
+ */
24
+ const errors_1 = require("../../../errors");
25
+ const context_1 = require("../../../core/context");
26
+ const types_1 = require("../../../types");
27
+ /** The key that stands for every tool a configuration does not name. */
28
+ exports.ALL_TOOLS = '*';
29
+ /** One tool's entry, validated and expanded. A mistake here is said at startup, not on the first call. */
30
+ function resolveTool(name, tool, where) {
31
+ if (tool === false)
32
+ return false;
33
+ const at = `${where}.${name === exports.ALL_TOOLS ? `'${exports.ALL_TOOLS}'` : name}`;
34
+ if (!tool || typeof tool !== 'object') {
35
+ throw new Error(`${at} must be an entitlement, { entitlement, usage }, or false.`);
36
+ }
37
+ const shorthand = 'currencyId' in tool || 'featureId' in tool;
38
+ if (shorthand && ('usage' in tool || 'entitlement' in tool)) {
39
+ throw new Error(`${at} mixes an entitlement's own fields with \`usage\` or \`entitlement\`, so one of them would be ` +
40
+ 'ignored. Write it as { entitlement: { currencyId }, usage }.');
41
+ }
42
+ const resolved = (shorthand ? { entitlement: tool } : tool);
43
+ if (resolved.usage !== undefined && typeof resolved.usage !== 'function') {
44
+ throw new Error(`${at}.usage must be a function from the tool call to the metrics it reports.`);
45
+ }
46
+ if (resolved.entitlement === undefined && !resolved.usage) {
47
+ throw new Error(`${at} names neither an entitlement to check nor a usage to report, so it would do nothing. ` +
48
+ 'Give it one of them, or false to leave the tool ungoverned.');
49
+ }
50
+ const invalid = resolved.entitlement && (0, types_1.entitlementError)(resolved.entitlement);
51
+ if (invalid)
52
+ throw invalid;
53
+ return resolved;
54
+ }
55
+ /** A configuration's tools, each validated and expanded once, when the graph is configured. */
56
+ function resolveTools(tools, where) {
57
+ return Object.fromEntries(Object.entries(tools ?? {}).map(([name, tool]) => [name, resolveTool(name, tool, where)]));
58
+ }
59
+ /**
60
+ * The graph's agents, each resolved once over the shared context and tools, so a step does no
61
+ * more than look its agent up. An agent's tools are laid over the shared ones, name by name.
62
+ */
63
+ function resolveAgents(shared, { tools, agents }) {
64
+ const sharedTools = resolveTools(tools, 'stigg.middleware tools');
65
+ const resolved = new Map();
66
+ for (const [name, agent] of Object.entries(agents ?? {})) {
67
+ // An agent's context is a default for every step it takes, so one key would dedupe them all
68
+ // into the first. The shared context is refused the same way, in `resolveContext`.
69
+ if (agent.stiggContext?.idempotencyKey !== undefined) {
70
+ 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
+ }
73
+ resolved.set(name, {
74
+ context: agent.stiggContext ? (0, context_1.overrideContext)(shared, agent.stiggContext) : shared,
75
+ tools: { ...sharedTools, ...resolveTools(agent.tools, `stigg.middleware agents.${name}.tools`) },
76
+ });
77
+ }
78
+ return { shared: { context: shared, tools: sharedTools }, agents: resolved };
79
+ }
80
+ /**
81
+ * The tool's own entry, else the catch-all - only the catch-all for a tool with no name, so a
82
+ * class name never matches an entry written for a tool's name. An exempted tool, or one not
83
+ * covered, is ungoverned. Own keys only: a tool named `toString` must not find `Object.prototype`'s.
84
+ */
85
+ function toolFor(tools, name) {
86
+ if (name !== undefined && Object.hasOwn(tools, name))
87
+ return tools[name] || undefined;
88
+ if (Object.hasOwn(tools, exports.ALL_TOOLS))
89
+ return tools[exports.ALL_TOOLS] || undefined;
90
+ return undefined;
91
+ }
92
+ /**
93
+ * How many issued calls are remembered, and how many steps can be in flight. An issued call is
94
+ * kept so a re-run finds its owner, and a step whose end never comes would otherwise stay for the
95
+ * life of the process, so past these the oldest go first.
96
+ */
97
+ const ISSUED_CALLS_LIMIT = 1_000;
98
+ const STEPS_LIMIT = 10_000;
99
+ /** Adds to a map that drops its oldest entry past `limit`, and hands back what it dropped. */
100
+ function remember(map, key, value, limit) {
101
+ map.set(key, value);
102
+ if (map.size <= limit)
103
+ return undefined;
104
+ const [oldest, dropped] = map.entries().next().value;
105
+ map.delete(oldest);
106
+ return dropped;
107
+ }
108
+ function createAgentMiddleware(session, config) {
109
+ /** Steps that started and have not ended, by run id. */
110
+ const steps = new Map();
111
+ /** The tool calls models issued, by call id - kept for re-runs, until the cap drops them. */
112
+ const issuedCalls = new Map();
113
+ const warnedUnlisted = new Set();
114
+ /**
115
+ * 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.
117
+ */
118
+ const agentFor = (agent) => {
119
+ const own = agent === undefined ? undefined : config.agents.get(agent);
120
+ if (!own && agent !== undefined && config.agents.size > 0 && !warnedUnlisted.has(agent)) {
121
+ warnedUnlisted.add(agent);
122
+ config.onUnlisted(agent);
123
+ }
124
+ return own ?? config.shared;
125
+ };
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;
129
+ if (!context.customerId)
130
+ throw (0, context_1.noCustomer)(config.missingCustomer);
131
+ return agent === undefined ? context : { ...context, agent };
132
+ };
133
+ /** The gate, raising rather than returning - the hooks it runs in have no return path. */
134
+ const check = async (context) => {
135
+ if (!(0, context_1.isEnforced)(context))
136
+ return;
137
+ const decision = await session.check(context);
138
+ if (!decision.hasAccess)
139
+ throw new errors_1.AccessDeniedError(decision);
140
+ };
141
+ /** A step in flight. One dropped to make room is never billed, so that is said, not silent. */
142
+ const rememberStep = (runId, step) => {
143
+ 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
+ }
147
+ };
148
+ /** The tool calls a reply asked for, kept until each tool has run. */
149
+ const rememberIssuedCalls = (issued, agent) => {
150
+ for (const call of issued)
151
+ remember(issuedCalls, call.id, { name: call.name, agent }, ISSUED_CALLS_LIMIT);
152
+ };
153
+ /** A tool step's context: priced by the tool's own entitlement, when it has one, and labelled with the tool. */
154
+ const toolContext = (agent, tool, name, attribution) => {
155
+ 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 };
158
+ };
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) => {
161
+ const options = { idempotencyKey };
162
+ if (!tool?.usage) {
163
+ await session.ingest(context, 1, options);
164
+ return;
165
+ }
166
+ try {
167
+ const metrics = tool.usage({ name: tool.name, input: tool.input, output });
168
+ if (!metrics || typeof metrics !== 'object' || Array.isArray(metrics)) {
169
+ throw new Error(`the usage of tool ${tool.name} returned ${String(metrics)}, not metrics by feature id`);
170
+ }
171
+ await session.ingestMetrics(context, metrics, options);
172
+ }
173
+ catch (err) {
174
+ session.failedToMeasure(context, err);
175
+ }
176
+ };
177
+ return {
178
+ async startModel(runId, attribution, eventName) {
179
+ const resolved = contextFor(agentFor(attribution.agent).context, attribution);
180
+ const context = resolved.eventName ? resolved : { ...resolved, eventName: { name: eventName } };
181
+ await check(context);
182
+ rememberStep(runId, { context });
183
+ },
184
+ // Awaited by the binding, so the agent's next check sees this spend. Safe to wait on: ingest
185
+ // never throws.
186
+ async endModel(runId, measurement, issued = []) {
187
+ const step = steps.get(runId);
188
+ rememberIssuedCalls(issued, step?.context.agent);
189
+ if (!step)
190
+ return;
191
+ steps.delete(runId);
192
+ if (!measurement) {
193
+ session.failedToMeasure(step.context, new Error('the model call ended without usage, so nothing was metered'));
194
+ return;
195
+ }
196
+ await session.ingestMeasurement(step.context, measurement);
197
+ },
198
+ async startTool(runId, { name, callId, kind, input }, attribution) {
199
+ // 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.
201
+ const issued = callId === undefined ? undefined : issuedCalls.get(callId);
202
+ const named = name ?? issued?.name;
203
+ const toolName = named ?? kind;
204
+ // Billed to the agent whose model asked for the call: a tool can run in a node of its own.
205
+ const owner = { ...attribution, agent: issued?.agent ?? attribution.agent };
206
+ const agent = agentFor(owner.agent);
207
+ const tool = toolName ? toolFor(agent.tools, named) : undefined;
208
+ if (!toolName || !tool)
209
+ return;
210
+ const context = toolContext(agent, tool, toolName, owner);
211
+ if (tool.entitlement)
212
+ await check(context);
213
+ rememberStep(runId, {
214
+ context,
215
+ 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,
218
+ });
219
+ },
220
+ async endTool(runId, output) {
221
+ const step = steps.get(runId);
222
+ if (!step)
223
+ return;
224
+ steps.delete(runId);
225
+ await billTool(step, output);
226
+ },
227
+ abandon(runId) {
228
+ steps.delete(runId);
229
+ },
230
+ };
231
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The LangChain and LangGraph binding: a callback handler over an agent graph's middleware.
3
+ *
4
+ * const stiggHandler = new StiggLangChainCallbackHandler({ stigg, stiggContext, agents });
5
+ * await agent.invoke(input, { callbacks: [stiggHandler], metadata: { stiggCustomerId } });
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.
11
+ *
12
+ * It blocks by default. LangChain swallows a handler's error unless `raiseError` is set, and runs
13
+ * an async handler in the background unless `awaitHandlers` is set too, so both are set - through
14
+ * the constructor, because `awaitHandlers` is derived inside `super()`. And LangChain copies a
15
+ * 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.
17
+ *
18
+ * The one file in `src/` that imports LangChain, reached only through the
19
+ * `@stigg/agent-sdk-js/langchain` entry.
20
+ */
21
+ import { BaseCallbackHandler } from '@langchain/core/callbacks/base';
22
+ import type { StiggAgentSDK } from '../../../stigg-agent-sdk';
23
+ import type { GovernedAgentMiddleware, MiddlewareOptions } from '../../../types';
24
+ /**
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.
28
+ */
29
+ export declare const CUSTOMER_METADATA_KEY = "stiggCustomerId";
30
+ export type StiggLangChainCallbackOptions = MiddlewareOptions & {
31
+ /** The SDK this handler governs through - one per environment, shared with everything else. */
32
+ stigg: StiggAgentSDK;
33
+ };
34
+ export declare class StiggLangChainCallbackHandler extends BaseCallbackHandler {
35
+ name: string;
36
+ /**
37
+ * The graph's middleware, which every hook below drives. Protected, for a subclass that traces:
38
+ * the handler itself exposes nothing more, because what it did is flushed through the SDK it was
39
+ * given (`stigg.flush()`), and reported through the `hooks` it was configured with.
40
+ */
41
+ protected readonly middleware: GovernedAgentMiddleware;
42
+ 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>;
45
+ handleLLMEnd(output: unknown, runId: string): Promise<void>;
46
+ 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>;
48
+ handleToolEnd(output: unknown, runId: string): Promise<void>;
49
+ handleToolError(_err: unknown, runId: string): void;
50
+ }
@@ -0,0 +1,151 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.StiggLangChainCallbackHandler = exports.CUSTOMER_METADATA_KEY = void 0;
4
+ /**
5
+ * The LangChain and LangGraph binding: a callback handler over an agent graph's middleware.
6
+ *
7
+ * const stiggHandler = new StiggLangChainCallbackHandler({ stigg, stiggContext, agents });
8
+ * await agent.invoke(input, { callbacks: [stiggHandler], metadata: { stiggCustomerId } });
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.
14
+ *
15
+ * It blocks by default. LangChain swallows a handler's error unless `raiseError` is set, and runs
16
+ * an async handler in the background unless `awaitHandlers` is set too, so both are set - through
17
+ * the constructor, because `awaitHandlers` is derived inside `super()`. And LangChain copies a
18
+ * 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.
20
+ *
21
+ * The one file in `src/` that imports LangChain, reached only through the
22
+ * `@stigg/agent-sdk-js/langchain` entry.
23
+ */
24
+ const base_1 = require("@langchain/core/callbacks/base");
25
+ const token_usage_1 = require("../../shared/token-usage");
26
+ /**
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.
30
+ */
31
+ exports.CUSTOMER_METADATA_KEY = 'stiggCustomerId';
32
+ /** What `createAgent({ name })` stamps on every run of that agent. */
33
+ 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
+ const NODE_METADATA_KEY = 'langgraph_node';
36
+ /** The counters LangChain's `usage_metadata` states at the top level, reported under its own names. */
37
+ const LANGCHAIN_METRICS = ['input_tokens', 'output_tokens', 'total_tokens'];
38
+ /** 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 } })\`, ` +
40
+ 'or give the handler a default in its `stiggContext: { customerId }`.';
41
+ class StiggLangChainCallbackHandler extends base_1.BaseCallbackHandler {
42
+ name = 'StiggLangChainCallbackHandler';
43
+ /**
44
+ * The graph's middleware, which every hook below drives. Protected, for a subclass that traces:
45
+ * the handler itself exposes nothing more, because what it did is flushed through the SDK it was
46
+ * given (`stigg.flush()`), and reported through the `hooks` it was configured with.
47
+ */
48
+ middleware;
49
+ constructor(options) {
50
+ super({ raiseError: true, _awaitHandler: true });
51
+ if (options instanceof StiggLangChainCallbackHandler) {
52
+ // LangChain's own copy, on its way into a nested run: the same graph, so the same middleware.
53
+ this.middleware = options.middleware;
54
+ return;
55
+ }
56
+ const { stigg, ...middlewareOptions } = options;
57
+ if (!stigg || typeof stigg.middleware !== 'function') {
58
+ throw new Error('`StiggLangChainCallbackHandler` needs `stigg`: the `StiggAgentSDK` it governs through.');
59
+ }
60
+ this.middleware = stigg.middleware({ ...middlewareOptions, missingCustomer: MISSING_CUSTOMER });
61
+ }
62
+ handleChatModelStart(_llm, _messages, runId, _parentRunId, _extraParams, _tags, metadata) {
63
+ return this.middleware.startModel(runId, attributionOf(metadata), 'langchain.chat_model');
64
+ }
65
+ // 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');
68
+ }
69
+ handleLLMEnd(output, runId) {
70
+ const message = output?.generations?.[0]?.[0]?.message;
71
+ 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);
73
+ }
74
+ handleLLMError(_err, runId) {
75
+ this.middleware.abandon(runId);
76
+ }
77
+ handleToolStart(tool, input, runId, _parentRunId, _tags, metadata, runName, toolCallId) {
78
+ // `runName` is set only for a tool built with `tool()`; a class-based one - `TavilySearch`,
79
+ // most packaged integrations - starts with none, and a serialized form that names only its
80
+ // class. The middleware names it from the model's own tool call, or by that class when no
81
+ // model called it.
82
+ 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));
84
+ }
85
+ 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));
89
+ }
90
+ handleToolError(_err, runId) {
91
+ this.middleware.abandon(runId);
92
+ }
93
+ }
94
+ 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
+ /** LangChain's `ToolMessage`, told by shape - the class is the application's copy, not ours. */
107
+ function isToolMessage(value) {
108
+ return (typeof value === 'object' &&
109
+ value !== null &&
110
+ value.type === 'tool' &&
111
+ 'tool_call_id' in value &&
112
+ 'content' in value);
113
+ }
114
+ /**
115
+ * A tool's input or output as its own author wrote it: LangChain hands both on serialized to JSON,
116
+ * so they are parsed back - and passed as they came when they are not JSON.
117
+ */
118
+ function decoded(value) {
119
+ if (typeof value !== 'string')
120
+ return value;
121
+ try {
122
+ return JSON.parse(value);
123
+ }
124
+ catch {
125
+ return value;
126
+ }
127
+ }
128
+ /** One generation's usage as a measurement, in LangChain's own vocabulary and the normalized one. */
129
+ function measurementOf(message) {
130
+ const usage = message?.usage_metadata;
131
+ if (!usage)
132
+ return undefined;
133
+ return {
134
+ metrics: pickNumbers(usage, LANGCHAIN_METRICS),
135
+ usage: (0, token_usage_1.normalizeUsage)(usage),
136
+ raw: usage,
137
+ // The provider's own id - `msg_…` for Anthropic - so a retried graph step that replays the same
138
+ // message is the same report.
139
+ ...(message.id ? { idempotencyKey: message.id } : {}),
140
+ };
141
+ }
142
+ /** The named counters that are numbers, as they were stated. */
143
+ function pickNumbers(source, names) {
144
+ const metrics = {};
145
+ for (const name of names) {
146
+ const value = source[name];
147
+ if (typeof value === 'number' && Number.isFinite(value))
148
+ metrics[name] = value;
149
+ }
150
+ return metrics;
151
+ }
@@ -122,6 +122,14 @@ export type ResolvedContext = {
122
122
  * itself to build its own.
123
123
  */
124
124
  attributionKey?: string;
125
+ /**
126
+ * The orchestrator's own name for the agent that took this step, when a middleware governs it -
127
+ * `lead`, `researcher`. A label for hooks and logs, never sent: what Stigg attributes by is
128
+ * `agentId`, which a customer-level run does not have.
129
+ */
130
+ agent?: string;
131
+ /** The paid tool this step ran, when a middleware checked one - a label, like `agent`. */
132
+ tool?: string;
125
133
  };
126
134
  /**
127
135
  * What nobody told the SDK, and it will not guess.
@@ -137,11 +145,10 @@ export declare const CONTEXT_DEFAULTS: {
137
145
  readonly ingestMode: "async";
138
146
  };
139
147
  /**
140
- * Validates and derives, once. Everything expensive about a context happens here rather than on
141
- * the call path - see the note at the top of this file for why that split exists.
148
+ * The one field a call cannot proceed without, refused the same way from every binding. Only where
149
+ * to put the customer differs, so the remedy is the caller's to word.
142
150
  */
143
- /** The one field a call cannot proceed without, said the same way wherever it is missing. */
144
- export declare const noCustomer: () => Error;
151
+ export declare const noCustomer: (remedy?: string) => Error;
145
152
  /**
146
153
  * The client-wide policy a context falls back to before the SDK's own defaults.
147
154
  *
@@ -153,6 +160,10 @@ export type ContextDefaults = {
153
160
  checkErrorAction?: CheckErrorAction;
154
161
  ingestMode?: IngestMode;
155
162
  };
163
+ /**
164
+ * Validates and derives, once. Everything expensive about a context happens here rather than on
165
+ * the call path - see the note at the top of this file for why that split exists.
166
+ */
156
167
  export declare function resolveContext(context: StiggContext, correlationId: string, { requireCustomer, defaults }?: {
157
168
  requireCustomer?: boolean;
158
169
  defaults?: ContextDefaults;
@@ -72,15 +72,17 @@ function planId(value) {
72
72
  }
73
73
  return named;
74
74
  }
75
+ /**
76
+ * The one field a call cannot proceed without, refused the same way from every binding. Only where
77
+ * to put the customer differs, so the remedy is the caller's to word.
78
+ */
79
+ const noCustomer = (remedy = 'Give `wrap(client, { stiggContext: { customerId } })` a default for every call through ' +
80
+ 'this client, or name one per call in `stiggContext`.') => new Error(`No customerId - every governance call is scoped to a tenant. ${remedy}`);
81
+ exports.noCustomer = noCustomer;
75
82
  /**
76
83
  * Validates and derives, once. Everything expensive about a context happens here rather than on
77
84
  * the call path - see the note at the top of this file for why that split exists.
78
85
  */
79
- /** The one field a call cannot proceed without, said the same way wherever it is missing. */
80
- const noCustomer = () => new Error('No customerId - every governance call is scoped to a tenant. Give `wrap(client, ' +
81
- '{ stiggContext: { customerId } })` a default for every call through this client, or name ' +
82
- 'one per call in `stiggContext`.');
83
- exports.noCustomer = noCustomer;
84
86
  function resolveContext(context, correlationId,
85
87
  // `wrap` passes false: a client whose calls each name their own customer is legal, and its
86
88
  // types already say which calls still owe one. Every path that reaches the engine resolves it.
@@ -27,6 +27,13 @@ export type HookContext = {
27
27
  correlationId: string;
28
28
  /** What the call gated on, when it gated on anything. Undefined on a metering-only run. */
29
29
  entitlementId?: string;
30
+ /**
31
+ * The agent that took the step, by the orchestrator's own name for it, when a middleware
32
+ * governs the run. Present at the customer level too, where nothing is attributed to an agent.
33
+ */
34
+ agent?: string;
35
+ /** The tool a step ran, when it was a paid tool a middleware checked. */
36
+ tool?: string;
30
37
  };
31
38
  export type SessionHooks = {
32
39
  /** Every check this client makes. The whole decision, already formatted as `.summary`. */
@@ -54,6 +61,11 @@ export type SessionDeps = {
54
61
  logger: Logger;
55
62
  onError: (error: EngineError, context: ErrorContext) => void;
56
63
  hooks: SessionHooks;
64
+ /**
65
+ * Whether each check and report is an OpenTelemetry span. On for an orchestrator's middleware,
66
+ * where an observability tool's trace is already open around the graph; off for `wrap`.
67
+ */
68
+ tracing?: boolean;
57
69
  };
58
70
  /** What a client did, for whoever wants to print it. Spans every context it served. */
59
71
  export type SessionSummary = {
@@ -181,6 +193,8 @@ export declare class GovernanceSession {
181
193
  private warnRounded;
182
194
  private warnIfNothingDrains;
183
195
  private record;
196
+ /** Every ingest result, however it ended: to its span, then to the application's hook. */
197
+ private emitIngest;
184
198
  /**
185
199
  * Hooks are instrumentation, so a throwing one must not reach the caller: it cannot turn an
186
200
  * allow into a denial, flip a delivered ingest to `ok: false`, or reject a promise the contract