@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.
@@ -29,6 +29,7 @@ const errors_1 = require("../errors");
29
29
  const headers_1 = require("../headers");
30
30
  const dimensions_1 = require("./dimensions");
31
31
  const hooks_1 = require("./hooks");
32
+ const telemetry_1 = require("./telemetry");
32
33
  const context_1 = require("./context");
33
34
  const types_1 = require("../types");
34
35
  /** What a context looks like to a hook: the attribution, and nothing a hook could change. */
@@ -37,6 +38,8 @@ const hookContext = (context) => ({
37
38
  dimensions: context.dimensions,
38
39
  correlationId: context.correlationId,
39
40
  entitlementId: context.checkedId,
41
+ ...(context.agent !== undefined ? { agent: context.agent } : {}),
42
+ ...(context.tool !== undefined ? { tool: context.tool } : {}),
40
43
  });
41
44
  /** A metric nobody can bill: refused where the mapper that invented it can still be found. */
42
45
  const emptyMetricNameError = () => new errors_1.EngineError('measure', 'a metric needs a name - an empty one reports to no feature at all.');
@@ -111,13 +114,14 @@ class GovernanceSession {
111
114
  * application routes on, not an exception it recovers from.
112
115
  */
113
116
  async check(context, requestedUsage = 1) {
117
+ const startedAt = Date.now();
114
118
  this.checks += 1;
115
119
  const { entitlement } = context;
116
120
  // Asked and answered, with nobody to ask: this context named no entitlement, so there is no
117
121
  // budget to consult and no refusal that could come back. Still counted and recorded, because
118
122
  // the caller did ask and `lastDecision` is where they read what they got.
119
123
  if (!entitlement)
120
- return this.record((0, decision_1.unenforcedDecision)(), context);
124
+ return this.record((0, decision_1.unenforcedDecision)(), context, startedAt);
121
125
  try {
122
126
  const engine = await this.deps.engine();
123
127
  // Before the check, not after: a customer Stigg has never seen holds no entitlement, so their
@@ -134,16 +138,16 @@ class GovernanceSession {
134
138
  // refused - an unknown plan, an archived customer - is refused here too, rather than
135
139
  // waved through by an `allow` that exists for outages, not for configuration mistakes.
136
140
  if (!reached && result.accessDeniedReason === CUSTOMER_NOT_FOUND) {
137
- return this.record((0, decision_1.policyDecision)(context.checkErrorAction === 'allow'), context);
141
+ return this.record((0, decision_1.policyDecision)(context.checkErrorAction === 'allow'), context, startedAt);
138
142
  }
139
- return this.record((0, decision_1.toDecision)(result, { customerId: context.customerId, entitlement: context.entitlement }), context);
143
+ return this.record((0, decision_1.toDecision)(result, { customerId: context.customerId, entitlement: context.entitlement }), context, startedAt);
140
144
  }
141
145
  catch (err) {
142
146
  const error = asEngineError('check', err);
143
147
  this.reportError(error, 'check', context);
144
148
  // Governance is fail-closed server-side; `'deny'` mirrors that, and `'allow'` - the default -
145
149
  // is the soft limit that keeps the application up while Stigg is unreachable.
146
- return this.record((0, decision_1.policyDecision)(context.checkErrorAction === 'allow'), context);
150
+ return this.record((0, decision_1.policyDecision)(context.checkErrorAction === 'allow'), context, startedAt);
147
151
  }
148
152
  }
149
153
  /** Applies the context's mapper to a token measurement. The path every binding uses. */
@@ -216,6 +220,7 @@ class GovernanceSession {
216
220
  send(context, metrics, sendOptions) {
217
221
  const { dimensions, idempotencyKey, raw, event } = sendOptions;
218
222
  const mode = context.ingestMode;
223
+ const startedAt = Date.now();
219
224
  // A broken value is one broken metric, not a broken call: the others measured fine and the
220
225
  // spend they describe already happened. Dropping only the bad one reports what is knowable.
221
226
  //
@@ -260,10 +265,10 @@ class GovernanceSession {
260
265
  // distinguishable from one that never tried, and reported to `onIngest` like every result.
261
266
  if (!Object.keys(sendable).length) {
262
267
  if (rejected)
263
- return Promise.resolve(this.failedIngest(context, idempotencyKey, rejected));
268
+ return Promise.resolve(this.failedIngest(context, idempotencyKey, rejected, startedAt));
264
269
  this.ingested += 1;
265
270
  const noop = { ok: true, reported: {}, idempotencyKey, mode, replayed: false };
266
- this.hook('onIngest', context, () => this.deps.hooks.onIngest?.(noop, hookContext(context)));
271
+ this.emitIngest(context, noop, startedAt);
267
272
  return Promise.resolve(noop);
268
273
  }
269
274
  const promise = this.deps
@@ -311,10 +316,10 @@ class GovernanceSession {
311
316
  this.ingestFailures += 1;
312
317
  this.reportError(rejected, rejected.operation, context, idempotencyKey);
313
318
  }
314
- this.hook('onIngest', context, () => this.deps.hooks.onIngest?.(landed, hookContext(context)));
319
+ this.emitIngest(context, landed, startedAt);
315
320
  return landed;
316
321
  })
317
- .catch((err) => this.failedIngest(context, idempotencyKey, asEngineError('ingest', err)))
322
+ .catch((err) => this.failedIngest(context, idempotencyKey, asEngineError('ingest', err), startedAt))
318
323
  .finally(() => {
319
324
  this.pending.delete(promise);
320
325
  });
@@ -403,11 +408,22 @@ class GovernanceSession {
403
408
  this.deps.logger.warn(`[stigg] reported ${names} but the response carried no credit balance - ${names.includes(',') ? 'these features are' : 'this feature is'} not mapped to ${checked}, so the budget this call checks ` +
404
409
  'against will never drain. Map it in Stigg, or report to the currency directly.');
405
410
  }
406
- record(decision, context) {
411
+ record(decision, context, startedAt) {
407
412
  this.decision = decision;
413
+ // Telemetry is instrumentation exactly as a hook is: it never reaches the caller.
414
+ if (this.deps.tracing) {
415
+ (0, hooks_1.safely)(() => (0, telemetry_1.traceCheck)(context, decision, startedAt), (err) => this.deps.logger.debug(`[stigg] check span failed: ${String(err)}`));
416
+ }
408
417
  this.hook('onDecision', context, () => this.deps.hooks.onDecision?.(decision, hookContext(context)));
409
418
  return decision;
410
419
  }
420
+ /** Every ingest result, however it ended: to its span, then to the application's hook. */
421
+ emitIngest(context, result, startedAt) {
422
+ if (this.deps.tracing) {
423
+ (0, hooks_1.safely)(() => (0, telemetry_1.traceIngest)(context, result, startedAt), (err) => this.deps.logger.debug(`[stigg] ingest span failed: ${String(err)}`));
424
+ }
425
+ this.hook('onIngest', context, () => this.deps.hooks.onIngest?.(result, hookContext(context)));
426
+ }
411
427
  /**
412
428
  * Hooks are instrumentation, so a throwing one must not reach the caller: it cannot turn an
413
429
  * allow into a denial, flip a delivered ingest to `ok: false`, or reject a promise the contract
@@ -446,7 +462,7 @@ class GovernanceSession {
446
462
  customerId: context?.customerId,
447
463
  }), (err) => this.deps.logger.warn(`[stigg] onError threw while reporting ${error.message}: ${String(err)}`));
448
464
  }
449
- failedIngest(context, idempotencyKey, error) {
465
+ failedIngest(context, idempotencyKey, error, startedAt = Date.now()) {
450
466
  this.ingestFailures += 1;
451
467
  // The error knows what failed - reading a vendor's usage is not the same event as the report
452
468
  // that would have followed it, and a context that says otherwise makes the two
@@ -472,7 +488,7 @@ class GovernanceSession {
472
488
  replayed: error.replayed ?? false,
473
489
  error,
474
490
  };
475
- this.hook('onIngest', context, () => this.deps.hooks.onIngest?.(failure, hookContext(context)));
491
+ this.emitIngest(context, failure, startedAt);
476
492
  return failure;
477
493
  }
478
494
  }
@@ -0,0 +1,8 @@
1
+ import type { AccessDecision, IngestResult } from '../types';
2
+ import type { ResolvedContext } from './context';
3
+ /** The instrumentation scope every Stigg span is reported under - what a span filter matches on. */
4
+ export declare const TRACER_NAME = "@stigg/agent-sdk-js";
5
+ /** One check, from when it was asked to when it was answered. */
6
+ export declare function traceCheck(context: ResolvedContext, decision: AccessDecision, startedAt: number): void;
7
+ /** One report, whether it landed, failed, or had nothing to send. */
8
+ export declare function traceIngest(context: ResolvedContext, result: IngestResult, startedAt: number): void;
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TRACER_NAME = void 0;
4
+ exports.traceCheck = traceCheck;
5
+ exports.traceIngest = traceIngest;
6
+ const sdk_identity_1 = require("../sdk-identity");
7
+ /** The instrumentation scope every Stigg span is reported under - what a span filter matches on. */
8
+ exports.TRACER_NAME = '@stigg/agent-sdk-js';
9
+ let api;
10
+ /** The application's copy of the API, loaded once - or `null` when it has none installed. */
11
+ function openTelemetry() {
12
+ if (api === undefined) {
13
+ try {
14
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
15
+ api = require('@opentelemetry/api');
16
+ }
17
+ catch {
18
+ api = null;
19
+ }
20
+ }
21
+ return api;
22
+ }
23
+ /** Asked for per span, so a provider registered after this package was loaded is still found. */
24
+ const tracer = (otel) => otel.trace.getTracer(exports.TRACER_NAME, sdk_identity_1.AGENT_SDK_VERSION);
25
+ /** Who the call was for: the attribution, as the check and the report both carry it. */
26
+ function attribution(context) {
27
+ const attributes = {
28
+ 'stigg.customer.id': context.customerId,
29
+ 'stigg.correlation_id': context.correlationId,
30
+ };
31
+ if (context.agent !== undefined)
32
+ attributes['gen_ai.agent.name'] = context.agent;
33
+ if (context.tool !== undefined)
34
+ attributes['gen_ai.tool.name'] = context.tool;
35
+ if (context.attributionKey && context.dimensions[context.attributionKey] !== undefined) {
36
+ attributes['stigg.agent.id'] = context.dimensions[context.attributionKey];
37
+ }
38
+ for (const [key, value] of Object.entries(context.dimensions))
39
+ attributes[`stigg.dimension.${key}`] = value;
40
+ if (context.eventName)
41
+ attributes['stigg.event.name'] = context.eventName.name;
42
+ return attributes;
43
+ }
44
+ /** One check, from when it was asked to when it was answered. */
45
+ function traceCheck(context, decision, startedAt) {
46
+ const otel = openTelemetry();
47
+ if (!otel)
48
+ return;
49
+ const attributes = {
50
+ ...attribution(context),
51
+ 'stigg.decision.has_access': decision.hasAccess,
52
+ 'stigg.decision.source': decision.source,
53
+ };
54
+ if (context.checkedId)
55
+ attributes['stigg.entitlement.id'] = context.checkedId;
56
+ if (decision.reason)
57
+ attributes['stigg.decision.reason'] = decision.reason;
58
+ if (decision.deniedBy)
59
+ attributes['stigg.decision.denied_by'] = decision.deniedBy;
60
+ if (decision.remaining !== undefined)
61
+ attributes['stigg.decision.remaining'] = decision.remaining;
62
+ if (decision.remedy)
63
+ attributes['stigg.decision.remedy'] = decision.remedy;
64
+ if (decision.isFallback)
65
+ attributes['stigg.decision.is_fallback'] = true;
66
+ const span = tracer(otel).startSpan('stigg.check', { startTime: startedAt, attributes });
67
+ if (!decision.hasAccess) {
68
+ span.recordException({ name: 'AccessDeniedError', message: decision.message });
69
+ span.setStatus({ code: otel.SpanStatusCode.ERROR, message: decision.summary });
70
+ }
71
+ span.end();
72
+ }
73
+ /** One report, whether it landed, failed, or had nothing to send. */
74
+ function traceIngest(context, result, startedAt) {
75
+ const otel = openTelemetry();
76
+ if (!otel)
77
+ return;
78
+ const attributes = {
79
+ ...attribution(context),
80
+ 'stigg.ingest.ok': result.ok,
81
+ 'stigg.ingest.mode': result.mode,
82
+ 'stigg.ingest.idempotency_key': result.idempotencyKey,
83
+ };
84
+ for (const [metric, value] of Object.entries(result.reported))
85
+ attributes[`stigg.metric.${metric}`] = value;
86
+ if (result.creditsConsumed !== undefined)
87
+ attributes['stigg.ingest.credits_consumed'] = result.creditsConsumed;
88
+ if (result.replayed !== undefined)
89
+ attributes['stigg.ingest.replayed'] = result.replayed;
90
+ const span = tracer(otel).startSpan('stigg.ingest', { startTime: startedAt, attributes });
91
+ if (!result.ok) {
92
+ const message = result.error instanceof Error ? result.error.message : String(result.error ?? 'ingest failed');
93
+ span.recordException({ name: 'EngineError', message });
94
+ span.setStatus({ code: otel.SpanStatusCode.ERROR, message });
95
+ }
96
+ span.end();
97
+ }
package/dist/errors.d.ts CHANGED
@@ -50,7 +50,14 @@ export declare class EngineError extends Error {
50
50
  export declare class GovernanceNotEnabledError extends Error {
51
51
  constructor(detail?: string);
52
52
  }
53
- /** Returns the decision when `err` is a governance denial - the one-liner for a binding catch. */
53
+ /**
54
+ * Returns the decision when `err` is a governance denial - the one-liner for a binding catch.
55
+ *
56
+ * Frameworks rarely let an error through untouched. Vendor SDKs wrap callback errors, LangChain
57
+ * wraps a model call's error once per middleware, and LangGraph reports two branches that failed
58
+ * in one step as an `AggregateError` - so the denial is looked for down the whole `cause` chain and
59
+ * inside every aggregated error, not only at the top.
60
+ */
54
61
  export declare function isAccessDenied(err: unknown): AccessDecision | undefined;
55
62
  export declare const isEngineError: (err: unknown) => err is EngineError;
56
63
  export declare const isGovernanceNotEnabled: (err: unknown) => err is GovernanceNotEnabledError;
package/dist/errors.js CHANGED
@@ -78,15 +78,37 @@ class GovernanceNotEnabledError extends Error {
78
78
  }
79
79
  }
80
80
  exports.GovernanceNotEnabledError = GovernanceNotEnabledError;
81
- /** Returns the decision when `err` is a governance denial - the one-liner for a binding catch. */
81
+ /**
82
+ * How far down a wrapped error is searched. Each LangChain middleware that wraps a model call adds
83
+ * one `MiddlewareError` around a denial, and a sub-agent's run nests its own inside its parent's,
84
+ * so a handful of levels is ordinary. The bound is only there so a cyclic `cause` cannot spin.
85
+ */
86
+ const MAX_CAUSE_DEPTH = 16;
87
+ /**
88
+ * Returns the decision when `err` is a governance denial - the one-liner for a binding catch.
89
+ *
90
+ * Frameworks rarely let an error through untouched. Vendor SDKs wrap callback errors, LangChain
91
+ * wraps a model call's error once per middleware, and LangGraph reports two branches that failed
92
+ * in one step as an `AggregateError` - so the denial is looked for down the whole `cause` chain and
93
+ * inside every aggregated error, not only at the top.
94
+ */
82
95
  function isAccessDenied(err) {
96
+ return findDenial(err, 0);
97
+ }
98
+ function findDenial(err, depth) {
83
99
  if (isKind(err, 'denied'))
84
100
  return err.decision;
85
- // Vendor SDKs wrap callback errors, so the denial can arrive one level down.
86
- const cause = err?.cause;
87
- if (cause && isKind(cause, 'denied'))
88
- return cause.decision;
89
- return undefined;
101
+ if (typeof err !== 'object' || err === null || depth >= MAX_CAUSE_DEPTH)
102
+ return undefined;
103
+ const { cause, errors } = err;
104
+ if (Array.isArray(errors)) {
105
+ for (const inner of errors) {
106
+ const decision = findDenial(inner, depth + 1);
107
+ if (decision)
108
+ return decision;
109
+ }
110
+ }
111
+ return cause === undefined ? undefined : findDenial(cause, depth + 1);
90
112
  }
91
113
  const isEngineError = (err) => isKind(err, 'engine');
92
114
  exports.isEngineError = isEngineError;
package/dist/index.d.ts CHANGED
@@ -19,11 +19,12 @@ export { describeDecision, remedyMessage } from './decision-text';
19
19
  export { GOVERNANCE_HEADERS, parseGovernanceHeaders } from './headers';
20
20
  export type { GovernanceHeaders, ParsedGovernanceHeaders } from './headers';
21
21
  export { consoleLogger, noopLogger } from './logger';
22
+ export { TRACER_NAME as STIGG_TRACER_NAME } from './core/telemetry';
22
23
  export type { Logger } from './logger';
23
24
  export { EngineError, AccessDeniedError, GovernanceNotEnabledError, isEngineError, isAccessDenied, isGovernanceNotEnabled, looksLikeGovernanceNotEnabled, } from './errors';
24
25
  export type { EngineCapabilities, EngineChainNode, EngineCheckParams, EngineCheckResult, EngineReportParams, EngineReportResult, EngineResolution, EntityInput, EventIngest, EntityPlacement, GovernanceEngine, } from './engine/engine';
25
26
  export { unitsPerTokens, vendorMetrics, emptyTokenUsage, entitlementId, entitlementKind, entitlementRef, toMeasurement, } from './types';
26
- export type { AgentBudget, AgentOptions, AgentScope, ClientGovernance, CheckErrorAction, ClientOptions, DecisionSource, DeregisterInput, Entitlement, AccessDecision, Remedy, WrappedClient, IngestContext, IngestMode, IngestResult, Measurement, RegisterInput, GatedSurface, ChatOf, CallContext, TokenUsage, Metrics, MetricsMapper, } from './types';
27
+ export type { AgentBudget, MiddlewareOptions, MiddlewareAgentOptions, MiddlewareTools, ToolGovernance, ToolUsageMapper, ToolCallMeasurement, GovernedAgentMiddleware, AgentOptions, AgentScope, ClientGovernance, CheckErrorAction, ClientOptions, DecisionSource, DeregisterInput, Entitlement, AccessDecision, Remedy, WrappedClient, IngestContext, IngestMode, IngestResult, Measurement, RegisterInput, GatedSurface, ChatOf, CallContext, TokenUsage, Metrics, MetricsMapper, } from './types';
27
28
  export { wrapClient } from './bindings/client/core/wrap-client';
28
29
  export { createClientRegistry, hasMethod } from './bindings/client/core/registry';
29
30
  export type { ClientRecipe, ClientRegistry, MethodRecipe, MetricsFor, MetricsOf, MeteredResponse, } from './bindings/client/core/types';
@@ -60,4 +61,5 @@ export type { TypeSafeMetric, TypeSafeMetrics } from './bindings/client/provider
60
61
  export { weightedMetrics } from './bindings/shared/weighted-metrics';
61
62
  export type { WeightedMetricsOptions } from './bindings/shared/weighted-metrics';
62
63
  export type { ProxyIngestInput } from './bindings/proxy';
64
+ export type { AgentMiddleware, IssuedToolCall, StepAttribution, ToolStep, } from './bindings/orchestrator/core/agent-middleware';
63
65
  export { normalizedMetrics, normalizeUsage } from './bindings/shared/token-usage';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.normalizeUsage = exports.normalizedMetrics = exports.weightedMetrics = exports.estimateFromState = exports.typesafeRecipe = exports.cloudflareRecipe = exports.bedrockRecipe = exports.databricksExternalRecipe = exports.databricksRecipe = exports.ollamaRecipe = exports.mistralRecipe = exports.groqRecipe = exports.litellmRecipe = exports.openrouterRecipe = exports.estimateFromMaxOutputTokens = exports.geminiChatRecipe = exports.geminiRecipe = exports.openAIRecipe = exports.estimateFromMaxTokens = exports.anthropicRecipe = exports.hasMethod = exports.createClientRegistry = exports.wrapClient = exports.toMeasurement = exports.entitlementRef = exports.entitlementKind = exports.entitlementId = exports.emptyTokenUsage = exports.vendorMetrics = exports.unitsPerTokens = exports.looksLikeGovernanceNotEnabled = exports.isGovernanceNotEnabled = exports.isAccessDenied = exports.isEngineError = exports.GovernanceNotEnabledError = exports.AccessDeniedError = exports.EngineError = exports.noopLogger = exports.consoleLogger = exports.parseGovernanceHeaders = exports.GOVERNANCE_HEADERS = exports.remedyMessage = exports.describeDecision = exports.flattenRaw = exports.RAW_PREFIX = exports.StiggAgentSDK = void 0;
3
+ exports.normalizeUsage = exports.normalizedMetrics = exports.weightedMetrics = exports.estimateFromState = exports.typesafeRecipe = exports.cloudflareRecipe = exports.bedrockRecipe = exports.databricksExternalRecipe = exports.databricksRecipe = exports.ollamaRecipe = exports.mistralRecipe = exports.groqRecipe = exports.litellmRecipe = exports.openrouterRecipe = exports.estimateFromMaxOutputTokens = exports.geminiChatRecipe = exports.geminiRecipe = exports.openAIRecipe = exports.estimateFromMaxTokens = exports.anthropicRecipe = exports.hasMethod = exports.createClientRegistry = exports.wrapClient = exports.toMeasurement = exports.entitlementRef = exports.entitlementKind = exports.entitlementId = exports.emptyTokenUsage = exports.vendorMetrics = exports.unitsPerTokens = exports.looksLikeGovernanceNotEnabled = exports.isGovernanceNotEnabled = exports.isAccessDenied = exports.isEngineError = exports.GovernanceNotEnabledError = exports.AccessDeniedError = exports.EngineError = exports.STIGG_TRACER_NAME = exports.noopLogger = exports.consoleLogger = exports.parseGovernanceHeaders = exports.GOVERNANCE_HEADERS = exports.remedyMessage = exports.describeDecision = exports.flattenRaw = exports.RAW_PREFIX = exports.StiggAgentSDK = void 0;
4
4
  /**
5
5
  * Public surface.
6
6
  *
@@ -28,6 +28,10 @@ Object.defineProperty(exports, "parseGovernanceHeaders", { enumerable: true, get
28
28
  var logger_1 = require("./logger");
29
29
  Object.defineProperty(exports, "consoleLogger", { enumerable: true, get: function () { return logger_1.consoleLogger; } });
30
30
  Object.defineProperty(exports, "noopLogger", { enumerable: true, get: function () { return logger_1.noopLogger; } });
31
+ // The OpenTelemetry instrumentation scope of every `stigg.check` / `stigg.ingest` span, for a span
32
+ // filter to match on - Langfuse's `shouldExportSpan`, for one, exports only scopes it knows.
33
+ var telemetry_1 = require("./core/telemetry");
34
+ Object.defineProperty(exports, "STIGG_TRACER_NAME", { enumerable: true, get: function () { return telemetry_1.TRACER_NAME; } });
31
35
  var errors_1 = require("./errors");
32
36
  Object.defineProperty(exports, "EngineError", { enumerable: true, get: function () { return errors_1.EngineError; } });
33
37
  Object.defineProperty(exports, "AccessDeniedError", { enumerable: true, get: function () { return errors_1.AccessDeniedError; } });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `@stigg/agent-sdk-js/langchain` - Stigg governance for LangChain and LangGraph.
3
+ *
4
+ * Its own entry point because it imports `@langchain/core`, which the package's main entry never
5
+ * does: an application that does not run LangChain never loads it. `@langchain/core` is an optional
6
+ * peer dependency, so this uses the copy the application already has - the one whose callback
7
+ * manager will be calling the handler.
8
+ *
9
+ * import { StiggAgentSDK } from "@stigg/agent-sdk-js";
10
+ * import { StiggLangChainCallbackHandler, AccessDeniedError } from "@stigg/agent-sdk-js/langchain";
11
+ */
12
+ export { StiggLangChainCallbackHandler, CUSTOMER_METADATA_KEY, } from '../bindings/orchestrator/langchain/callback-handler';
13
+ export type { StiggLangChainCallbackOptions } from '../bindings/orchestrator/langchain/callback-handler';
14
+ export { AccessDeniedError, isAccessDenied } from '../errors';
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isAccessDenied = exports.AccessDeniedError = exports.CUSTOMER_METADATA_KEY = exports.StiggLangChainCallbackHandler = void 0;
4
+ /**
5
+ * `@stigg/agent-sdk-js/langchain` - Stigg governance for LangChain and LangGraph.
6
+ *
7
+ * Its own entry point because it imports `@langchain/core`, which the package's main entry never
8
+ * does: an application that does not run LangChain never loads it. `@langchain/core` is an optional
9
+ * peer dependency, so this uses the copy the application already has - the one whose callback
10
+ * manager will be calling the handler.
11
+ *
12
+ * import { StiggAgentSDK } from "@stigg/agent-sdk-js";
13
+ * import { StiggLangChainCallbackHandler, AccessDeniedError } from "@stigg/agent-sdk-js/langchain";
14
+ */
15
+ var callback_handler_1 = require("../bindings/orchestrator/langchain/callback-handler");
16
+ Object.defineProperty(exports, "StiggLangChainCallbackHandler", { enumerable: true, get: function () { return callback_handler_1.StiggLangChainCallbackHandler; } });
17
+ Object.defineProperty(exports, "CUSTOMER_METADATA_KEY", { enumerable: true, get: function () { return callback_handler_1.CUSTOMER_METADATA_KEY; } });
18
+ // What a refusal is, re-exported so a catch block needs one import: `e instanceof AccessDeniedError`.
19
+ var errors_1 = require("../errors");
20
+ Object.defineProperty(exports, "AccessDeniedError", { enumerable: true, get: function () { return errors_1.AccessDeniedError; } });
21
+ Object.defineProperty(exports, "isAccessDenied", { enumerable: true, get: function () { return errors_1.isAccessDenied; } });
@@ -2,12 +2,12 @@ import { type ResolvedConfig, type StiggAgentSDKOptions } from './config';
2
2
  import { type EngineResolution, type GovernanceEngine } from './engine/engine';
3
3
  import type { StiggContext } from './core/context';
4
4
  import type { SessionSummary } from './core/session';
5
- import type { Metrics, ClientOptions, DeregisterInput, AccessDecision, WrappedClient, PathsOf, IngestContext, IngestResult, RegisterInput } from './types';
5
+ import type { Metrics, GovernedAgentMiddleware, MiddlewareOptions, ClientOptions, DeregisterInput, AccessDecision, WrappedClient, PathsOf, IngestContext, IngestResult, RegisterInput } from './types';
6
6
  import type { GovernanceHeaders } from './headers';
7
7
  /**
8
8
  * Recipes are data - method paths and usage extractors - so wiring them here costs nothing at
9
- * runtime and pulls in no vendor package. An orchestration adapter is different: it needs its
10
- * framework loaded, so it stays an explicit subpath import.
9
+ * runtime and pulls in no vendor package. The LangChain handler, which does load its framework,
10
+ * is reached only through the `@stigg/agent-sdk-js/langchain` entry, never from here.
11
11
  */
12
12
  /**
13
13
  * Order matters: the gateways are matched before the plain OpenAI adapter, because every one of
@@ -97,11 +97,11 @@ export declare const DEFAULT_RECIPES: ({
97
97
  totalTokensMetric: "total_tokens";
98
98
  matches: (client: unknown) => boolean;
99
99
  methods: ({
100
- path: ("stream" | "chat" | "complete" | "parse" | "parseStream" | "fim" | "agents")[];
100
+ path: ("agents" | "stream" | "chat" | "complete" | "parse" | "parseStream" | "fim")[];
101
101
  attachStream: (stream: unknown, ingest: (measurement: import("./types").Measurement) => void) => void;
102
102
  extractUsage?: undefined;
103
103
  } | {
104
- path: ("stream" | "chat" | "complete" | "parse" | "parseStream" | "fim" | "agents")[];
104
+ path: ("agents" | "stream" | "chat" | "complete" | "parse" | "parseStream" | "fim")[];
105
105
  extractUsage: (result: import("./bindings/client/providers/mistral/types").MistralCompletionResponse | undefined) => import("./types").Measurement | undefined;
106
106
  attachStream?: undefined;
107
107
  })[];
@@ -242,6 +242,20 @@ export declare class StiggAgentSDK {
242
242
  * see {@link GovernanceSession.warmUp} for which cache that is and when there is one at all.
243
243
  */
244
244
  wrap<T extends object, O extends ClientOptions>(client: T, options?: O): WrappedClient<T, O['stiggContext'], PathsOf<O['recipe'], T>>;
245
+ /**
246
+ * An agent graph's governance middleware - the orchestration counterpart to `wrap`.
247
+ *
248
+ * There is no single client to gate in a graph: it is several agents, each spending against its
249
+ * own entitlement. So the graph is configured once - the shared context, and each agent's own
250
+ * under `agents` - and every step it takes is checked and billed as the agent that took it. A
251
+ * refusal is thrown as `AccessDeniedError` from inside the refused agent; what that means for the
252
+ * run is the graph's to decide.
253
+ *
254
+ * What comes back is framework-neutral: the start and end of each model call and tool. A
255
+ * framework binding drives it - `new StiggLangChainCallbackHandler({ stigg, ...options })` from
256
+ * `@stigg/agent-sdk-js/langchain` builds one of these - and so can a harness of your own.
257
+ */
258
+ middleware(options?: MiddlewareOptions): GovernedAgentMiddleware;
245
259
  /**
246
260
  * Hangs the governance handle off the wrapped client. Refuses to overwrite a `stigg` the vendor
247
261
  * already has: silently replacing one of its own properties would be a worse surprise.
@@ -36,6 +36,7 @@ const openrouter_recipe_1 = require("./bindings/client/gateways/openrouter/openr
36
36
  const typesafe_recipe_1 = require("./bindings/client/providers/typesafe/typesafe-recipe");
37
37
  const registry_1 = require("./bindings/client/core/registry");
38
38
  const wrap_client_1 = require("./bindings/client/core/wrap-client");
39
+ const agent_middleware_1 = require("./bindings/orchestrator/core/agent-middleware");
39
40
  const token_usage_1 = require("./bindings/shared/token-usage");
40
41
  const context_1 = require("./core/context");
41
42
  const session_1 = require("./core/session");
@@ -43,8 +44,8 @@ const hooks_1 = require("./core/hooks");
43
44
  const types_1 = require("./types");
44
45
  /**
45
46
  * Recipes are data - method paths and usage extractors - so wiring them here costs nothing at
46
- * runtime and pulls in no vendor package. An orchestration adapter is different: it needs its
47
- * framework loaded, so it stays an explicit subpath import.
47
+ * runtime and pulls in no vendor package. The LangChain handler, which does load its framework,
48
+ * is reached only through the `@stigg/agent-sdk-js/langchain` entry, never from here.
48
49
  */
49
50
  /**
50
51
  * Order matters: the gateways are matched before the plain OpenAI adapter, because every one of
@@ -398,6 +399,43 @@ class StiggAgentSDK {
398
399
  void session.warmUp(context);
399
400
  return governed;
400
401
  }
402
+ /**
403
+ * An agent graph's governance middleware - the orchestration counterpart to `wrap`.
404
+ *
405
+ * There is no single client to gate in a graph: it is several agents, each spending against its
406
+ * own entitlement. So the graph is configured once - the shared context, and each agent's own
407
+ * under `agents` - and every step it takes is checked and billed as the agent that took it. A
408
+ * refusal is thrown as `AccessDeniedError` from inside the refused agent; what that means for the
409
+ * run is the graph's to decide.
410
+ *
411
+ * What comes back is framework-neutral: the start and end of each model call and tool. A
412
+ * framework binding drives it - `new StiggLangChainCallbackHandler({ stigg, ...options })` from
413
+ * `@stigg/agent-sdk-js/langchain` builds one of these - and so can a harness of your own.
414
+ */
415
+ middleware(options = {}) {
416
+ // Started, not awaited, and caught - for the reasons `wrap` gives.
417
+ void this.ensureStarted().catch((err) => this.reportStartupFailure(err));
418
+ const shared = (0, context_1.resolveContext)(options.stiggContext ?? {}, (0, node_crypto_1.randomUUID)(), {
419
+ requireCustomer: false,
420
+ defaults: this.contextDefaults,
421
+ });
422
+ const { shared: sharedAgent, agents } = (0, agent_middleware_1.resolveAgents)(shared, options);
423
+ // Spans for the observability tool already tracing the graph - out of the box, for orchestrators only.
424
+ const session = this.newSession(options.hooks, { tracing: true });
425
+ const middleware = (0, agent_middleware_1.createAgentMiddleware)(session, {
426
+ shared: sharedAgent,
427
+ agents,
428
+ onUnlisted: (agent) => this.config.logger.warn(`[stigg] agent "${agent}" is not listed in the middleware's \`agents\`, so its calls are ` +
429
+ 'governed by the shared `stiggContext` alone - at the customer level unless that context ' +
430
+ 'names an agent. List it to govern it as itself.'),
431
+ missingCustomer: options.missingCustomer ??
432
+ 'Name one per run, or give the middleware a default in `stiggContext: { customerId }`.',
433
+ });
434
+ this.attachGovernance(middleware, session, shared);
435
+ if (shared.customerId)
436
+ void session.warmUp(shared);
437
+ return middleware;
438
+ }
401
439
  /**
402
440
  * Hangs the governance handle off the wrapped client. Refuses to overwrite a `stigg` the vendor
403
441
  * already has: silently replacing one of its own properties would be a worse surprise.
@@ -457,8 +495,9 @@ class StiggAgentSDK {
457
495
  get contextDefaults() {
458
496
  return { checkErrorAction: this.config.checkErrorAction, ingestMode: this.config.ingest };
459
497
  }
460
- newSession(hooks) {
498
+ newSession(hooks, { tracing = false } = {}) {
461
499
  const session = new session_1.GovernanceSession({
500
+ tracing,
462
501
  engine: () => this.ensureStarted().then((state) => state.engine),
463
502
  provisionCustomer: (customerId, planId) => this.provisionCustomer(customerId, planId),
464
503
  logger: this.config.logger,
package/dist/types.d.ts CHANGED
@@ -13,6 +13,7 @@ import type { StiggContext } from './core/context';
13
13
  import type { SessionSummary, SessionHooks } from './core/session';
14
14
  import type { GovernanceHeaders } from './headers';
15
15
  import type { ClientRecipe } from './bindings/client/core/types';
16
+ import type { AgentMiddleware } from './bindings/orchestrator/core/agent-middleware';
16
17
  import type { ObservedVendor } from './bindings/shared/token-usage';
17
18
  import type { EventIngest } from './engine/engine';
18
19
  /**
@@ -347,6 +348,102 @@ export type ClientOptions = {
347
348
  export type ClientContext = Omit<StiggContext, 'idempotencyKey'> & {
348
349
  idempotencyKey?: never;
349
350
  };
351
+ /**
352
+ * The governance of one agent graph: `stigg.middleware(options)`. Framework-neutral - a framework
353
+ * binding drives it (for LangChain and LangGraph, `StiggLangChainCallbackHandler`, which takes the
354
+ * same options), and so can a customer's own harness.
355
+ *
356
+ * A graph is configured here rather than wired call by call: the shared settings once, and each
357
+ * agent that should be governed as itself under `agents`, by the name its framework knows it by.
358
+ * Every step is checked and billed as the agent that took it, so one agent running dry stops that
359
+ * agent and nothing else.
360
+ *
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.
364
+ */
365
+ export type MiddlewareOptions = {
366
+ /**
367
+ * The context every agent starts from - what is checked, how usage prices, how it is reported.
368
+ * An agent's own `stiggContext` is laid over it, so this is where the parts they share go once.
369
+ * No `idempotencyKey`, for the reason a client's default has none: every step would share it.
370
+ */
371
+ stiggContext?: ClientContext;
372
+ /**
373
+ * The tools every agent may call that are governed, by tool name - `'*'` for every tool not
374
+ * named. An agent's own `tools` are laid over these. A tool not covered runs ungoverned.
375
+ */
376
+ tools?: MiddlewareTools;
377
+ /**
378
+ * Everything the graph reports back while it works. Each call's `HookContext.agent` says which
379
+ * agent took the step, so one hook can tell them apart.
380
+ */
381
+ hooks?: SessionHooks;
382
+ /**
383
+ * The agents governed as themselves, by the name the framework stamps on their steps - on
384
+ * LangChain, the `name` given to `createAgent`, or the node's name in a hand-built LangGraph graph.
385
+ *
386
+ * An agent left out is not left ungoverned: its steps run under the shared `stiggContext` alone -
387
+ * at the customer level, unless that context names an agent - and the SDK says once that it was
388
+ * not listed. Leave `agents` out entirely and every step is governed that way, which is a
389
+ * one-agent graph or a customer-level one.
390
+ */
391
+ agents?: Record<string, MiddlewareAgentOptions>;
392
+ /**
393
+ * Where this framework's caller puts the customer, for the refusal a step with none raises. Set
394
+ * by a framework binding; a harness of your own can name its own convention here.
395
+ */
396
+ missingCustomer?: string;
397
+ };
398
+ /** One agent's part of a graph's middleware, laid over the shared settings. */
399
+ export type MiddlewareAgentOptions = {
400
+ /** This agent's context over the shared one - typically its `agentId`, and what it gates on. */
401
+ stiggContext?: ClientContext;
402
+ /** This agent's tools over the shared ones - its own paid tools, or `false` to exempt one. */
403
+ tools?: MiddlewareTools;
404
+ };
405
+ /**
406
+ * The governed tools of a graph, by tool name, with `'*'` standing for every tool not named.
407
+ *
408
+ * `false` exempts a tool, `'*'` included. It is what a sub-agent held as a tool needs under `'*'`:
409
+ * that sub-agent's own model calls are already checked and billed, so billing the tool that runs it
410
+ * too would charge the same work twice.
411
+ */
412
+ export type MiddlewareTools = Record<string, Entitlement | ToolGovernance | false>;
413
+ /**
414
+ * How one tool is governed. An entitlement alone is shorthand for `{ entitlement }`.
415
+ *
416
+ * - `entitlement` - checked before the tool runs, and refused like a model call.
417
+ * - `usage` - what a call that succeeded reports, from the tool's own input and output, as a
418
+ * client's `usageMapper` does for a model call. Left out, a call reports one unit against
419
+ * `entitlement`. Given without `entitlement`, the tool is metered and never refused.
420
+ */
421
+ export type ToolGovernance = {
422
+ entitlement: Entitlement;
423
+ usage?: ToolUsageMapper;
424
+ } | {
425
+ entitlement?: undefined;
426
+ usage: ToolUsageMapper;
427
+ };
428
+ /**
429
+ * Turns one tool call into the metrics to report. Never throws into the run: a mapper that throws
430
+ * is a metering failure, reported through `onError` like any other.
431
+ */
432
+ export type ToolUsageMapper = (call: ToolCallMeasurement) => Metrics;
433
+ /** One tool call that succeeded, as a `ToolUsageMapper` sees it. */
434
+ export type ToolCallMeasurement = {
435
+ /** The tool's name. */
436
+ name: string;
437
+ /** What the tool was called with - its arguments, parsed, when the framework passed them as JSON. */
438
+ input: unknown;
439
+ /** What the tool returned - parsed, when the framework passed it on as JSON. */
440
+ output: unknown;
441
+ };
442
+ /** A graph's middleware, carrying the same governance handle a wrapped client does. */
443
+ export type GovernedAgentMiddleware = AgentMiddleware & {
444
+ /** What this graph did, and what is still in flight. */
445
+ readonly stigg: ClientGovernance;
446
+ };
350
447
  /**
351
448
  * `register` - giving an agent an identity, a budget, and a place in the tree.
352
449
  *